Merge master into feature/workspace-picker-composer
This commit is contained in:
@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
|
||||
Naming notes:
|
||||
|
||||
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: dec4d71ca2d323fe05f918dd3bf4709cfa01878e
|
||||
README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009
|
||||
README.md: c18a46b7131f7782be68f3c96fa99b89615de471
|
||||
README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851
|
||||
|
||||
@@ -2,52 +2,53 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
|
||||
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
@@ -61,6 +62,6 @@ New packages join existing groups; new groups update their README and this table
|
||||
|
||||
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
**Extension plugins depend on Service Definitions, never concrete providers.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities separate Service Definition / Service provider / Consumer roles when they evolve independently; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
|
||||
@@ -2,54 +2,55 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
包位于 `packages/<group>/<pkg>/`;组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的包/ctx 键映射。**
|
||||
包按组置于 `packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |
|
||||
| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定接口 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定接口 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定接口 |
|
||||
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:Service Definition + 本地进程树提供方 | 产品:稳定接口 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定接口 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定接口 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定接口 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定接口 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定接口 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定接口 |
|
||||
| [`skill/`](skill/README.md) | skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定接口 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定接口 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定接口 |
|
||||
| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定接口 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件提供方 | 产品:稳定接口 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
@@ -61,6 +62,6 @@
|
||||
|
||||
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。
|
||||
|
||||
**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力在 Service Definition/Service provider/Consumer 角色需要独立演进时将其分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
|
||||
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/README.md
|
||||
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
|
||||
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
|
||||
README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5
|
||||
README.zh.md: fb9f419e23aef5b3bdf0bdd486b331eb2f3f236a
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
|
||||
The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server. |
|
||||
|
||||
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
|
||||
The server contract is documented in [`acp/README.md`](acp/README.md).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。
|
||||
ACP(Agent Client Protocol)组通过该协议将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
服务器约定见 [`acp/README.md`](acp/README.md)。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
|
||||
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
|
||||
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。
|
||||
|
||||
## 协议契约
|
||||
## 协议约定
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
}
|
||||
|
||||
/** Plugin config: the provider/model target used for each ACP-created agent. */
|
||||
/** Plugin config: the provider/model selection used for each ACP-created agent. */
|
||||
export interface AcpConfig {
|
||||
/** Provider route for created agents. */
|
||||
provider?: string
|
||||
@@ -100,7 +100,7 @@ interface SessionRecord {
|
||||
/**
|
||||
* Mount the automation-only ACP server.
|
||||
* @param ctx - Cordis context carrying the agent factory and session events.
|
||||
* @param config - Initial provider/model target and optional test transport.
|
||||
* @param config - Initial provider/model selection and optional test transport.
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// ACP handlers execute outside this plugin's injection scope, so capture the
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sdk/README.md
|
||||
README.md: 3f99d6d45ddc64ac068457b0d03e533d21451a5f
|
||||
README.zh.md: 003383c5f6238b37893927813959d52d96350c3e
|
||||
# pnpm run verify-translation-pairing --write packages/api/README.md
|
||||
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
|
||||
README.zh.md: f4e882570d2dc929cca466e0bdb05bb2943f2c93
|
||||
17
packages/api/README.md
Normal file
17
packages/api/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/ — Remote API layers
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts.
|
||||
- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy.
|
||||
17
packages/api/README.zh.md
Normal file
17
packages/api/README.zh.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/:Remote API 层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 约定,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务约定。
|
||||
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。
|
||||
6
packages/api/gateway/README.i18n.yaml
Normal file
6
packages/api/gateway/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
|
||||
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a
|
||||
39
packages/api/gateway/README.md
Normal file
39
packages/api/gateway/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
|
||||
|
||||
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
## Client service: `ClientRemote` (ctx key: `remote`)
|
||||
|
||||
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package dispatches application calls and registers no prompt, tool, or session event.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
39
packages/api/gateway/README.zh.md
Normal file
39
packages/api/gateway/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientRemote`(ctx key:`remote`)
|
||||
|
||||
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;被调用的业务服务负责产生任何模型可见结果。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
66
packages/api/gateway/package.json
Normal file
66
packages/api/gateway/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
498
packages/api/gateway/src/client/index.ts
Normal file
498
packages/api/gateway/src/client/index.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install traced `remote.<namespace>` services; no JavaScript Proxy
|
||||
* participates in method lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
interface ScopedProjection {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
interface DirectMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly token: MountToken
|
||||
}
|
||||
|
||||
interface ScopedMethod extends DirectMethod {
|
||||
readonly projection: ScopedProjection
|
||||
}
|
||||
|
||||
interface RemoteMethodRecord {
|
||||
direct?: DirectMethod
|
||||
scoped?: ScopedMethod
|
||||
}
|
||||
|
||||
interface BoundContextIdentity {
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface RemoteNamespaceHandle {
|
||||
readonly service: RemoteNamespaceService
|
||||
readonly dispose: TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypeRTClientRemote
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by the Client assembly. */
|
||||
remote: ClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client Remote service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
const callerCtx = this.ctx
|
||||
const owned = callerCtx.effect(async () => {
|
||||
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
|
||||
return () => this.enqueue(dispose)
|
||||
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
|
||||
await owned
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
private async mountContribution(
|
||||
callerCtx: Context,
|
||||
contribution: TypeRTRemoteContribution,
|
||||
): Promise<TypeRTDisposer> {
|
||||
this.validateContribution(contribution)
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
|
||||
} catch (error) {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
}
|
||||
}
|
||||
|
||||
private validateContribution(contribution: TypeRTRemoteContribution): void {
|
||||
const direct = new Map<string, Set<string>>()
|
||||
const scoped = new Map<string, Set<string>>()
|
||||
const add = (
|
||||
table: Map<string, Set<string>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
kind: 'direct' | 'scoped',
|
||||
): void => {
|
||||
const methods = table.get(descriptor.namespace) ?? new Set<string>()
|
||||
if (methods.has(descriptor.method)) {
|
||||
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
|
||||
}
|
||||
methods.add(descriptor.method)
|
||||
table.set(descriptor.namespace, methods)
|
||||
const namespace = this.namespaces.get(descriptor.namespace)?.service
|
||||
if (namespace?.has(kind, descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
requireStrictDescriptor(descriptor)
|
||||
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
|
||||
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
|
||||
}
|
||||
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
|
||||
for (const namespace of namespaces) {
|
||||
const service = this.namespaces.get(namespace)?.service
|
||||
if (service === undefined) {
|
||||
if (namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
|
||||
}
|
||||
const serviceKey = remoteServiceKey(namespace)
|
||||
const property = this.ownerCtx.reflect.props[serviceKey]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
|
||||
}
|
||||
}
|
||||
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
|
||||
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
|
||||
else service.assertMethodAvailable(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(await this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installDirect(descriptor, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('direct', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installScoped(descriptor, projection, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('scoped', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
|
||||
let namespace = this.namespaces.get(name)
|
||||
if (namespace !== undefined) return namespace
|
||||
let service: RemoteNamespaceService | undefined
|
||||
const fiber = this.ownerCtx.plugin({
|
||||
name: remoteServiceKey(name),
|
||||
apply: (ctx: Context) => {
|
||||
service = new RemoteNamespaceService(
|
||||
ctx,
|
||||
name,
|
||||
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
|
||||
)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await fiber
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
|
||||
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
namespace = { service, dispose: fiber.dispose }
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
}
|
||||
|
||||
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
|
||||
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
|
||||
this.namespaces.delete(name)
|
||||
await namespace.dispose()
|
||||
}
|
||||
|
||||
private invokeMethod(
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
if (identity !== undefined) {
|
||||
return this.invoke(
|
||||
scoped.descriptor,
|
||||
scoped.projection,
|
||||
scoped.token,
|
||||
callerCtx,
|
||||
values,
|
||||
{ value: identity },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (direct !== undefined) {
|
||||
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
}
|
||||
if (scoped !== undefined) {
|
||||
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
}
|
||||
throw new Error('client api: Remote method is no longer mounted')
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
const contract = descriptor.cancellation === undefined
|
||||
? `${String(expected)} argument(s)`
|
||||
: `${String(expected)} business argument(s) plus an optional AbortSignal`
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = boundIdentity === undefined
|
||||
? this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
: undefined
|
||||
if (boundIdentity === undefined && binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = boundIdentity === undefined
|
||||
? binder?.identity(callerCtx)
|
||||
: boundIdentity.value
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
|
||||
}
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
private readonly namespace: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
private readonly invokeRemote: InvokeRemote,
|
||||
) {
|
||||
super(ctx, remoteServiceKey(name))
|
||||
this.namespace = name
|
||||
}
|
||||
|
||||
assertMethodAvailable(method: string): void {
|
||||
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
|
||||
if (method in this && !this.methods.has(method)) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
get empty(): boolean {
|
||||
return this.methods.size === 0
|
||||
}
|
||||
|
||||
has(kind: 'direct' | 'scoped', method: string): boolean {
|
||||
return this.methods.get(method)?.[kind] !== undefined
|
||||
}
|
||||
|
||||
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
|
||||
this.install(descriptor.method, 'direct', { descriptor, token })
|
||||
}
|
||||
|
||||
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
|
||||
}
|
||||
|
||||
private install(method: string, kind: 'direct', value: DirectMethod): void
|
||||
private install(method: string, kind: 'scoped', value: ScopedMethod): void
|
||||
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
|
||||
this.assertMethodAvailable(method)
|
||||
let record = this.methods.get(method)
|
||||
const fresh = record === undefined
|
||||
record ??= {}
|
||||
if (fresh) {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
const scoped = current?.scoped
|
||||
return (...args: unknown[]) => {
|
||||
return this.invokeRemote(direct, scoped, callerCtx, args)
|
||||
}
|
||||
},
|
||||
})
|
||||
this.methods.set(method, record)
|
||||
}
|
||||
if (kind === 'direct') record.direct = value
|
||||
else record.scoped = value as ScopedMethod
|
||||
}
|
||||
|
||||
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
|
||||
const record = this.methods.get(method)
|
||||
const current = record?.[kind]
|
||||
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
|
||||
if (record === undefined || current?.token !== token) return
|
||||
if (kind === 'direct') delete record.direct
|
||||
else delete record.scoped
|
||||
if (record.direct !== undefined || record.scoped !== undefined) return
|
||||
this.methods.delete(method)
|
||||
Reflect.deleteProperty(this, method)
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
|
||||
|
||||
function remoteServiceKey(namespace: string): string {
|
||||
return `remote.${namespace}`
|
||||
}
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
function mountActive(token: MountToken): boolean {
|
||||
return token.active
|
||||
}
|
||||
|
||||
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
return {
|
||||
context: descriptor.invocation.context,
|
||||
wire: descriptor.invocation.wire,
|
||||
codec: descriptor.invocation.codec,
|
||||
}
|
||||
}
|
||||
if (descriptor.scope === undefined) return undefined
|
||||
const lookupParameters = descriptor.parameters
|
||||
.map((parameter, index) => ({ parameter, index }))
|
||||
.filter(candidate => candidate.parameter.source === 'lookup')
|
||||
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
if (selected === undefined
|
||||
|| selected.parameter.wire !== descriptor.scope.wire
|
||||
|| selected.parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
context: descriptor.scope.context,
|
||||
wire: descriptor.scope.wire,
|
||||
codec: selected.parameter.codec,
|
||||
parameterIndex: selected.index,
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
requireStrictCodec(descriptor.result, endpoint, 'result')
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
}
|
||||
|
||||
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
try {
|
||||
return codec.schema.parse(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
}
|
||||
638
packages/api/gateway/src/index.ts
Normal file
638
packages/api/gateway/src/index.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
readonly cause?: unknown
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
interface ResolvedBinding {
|
||||
readonly binding: TypeRTGatewayBinding
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
/** Machine-readable failure category. */
|
||||
readonly code: TypertGatewayErrorCode
|
||||
/** Canonical `<namespace>/<method>` endpoint. */
|
||||
readonly endpoint: string
|
||||
/** Affected wire field when the failure is field-specific. */
|
||||
readonly field: string | undefined
|
||||
|
||||
/**
|
||||
* Construct a Gateway failure without embedding boundary values in its message.
|
||||
* @param code - stable failure category.
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param message - correction-oriented diagnostic without sensitive values.
|
||||
* @param options - optional field and contained cause.
|
||||
*/
|
||||
constructor(
|
||||
code: TypertGatewayErrorCode,
|
||||
endpoint: string,
|
||||
message: string,
|
||||
options: GatewayErrorOptions = {},
|
||||
) {
|
||||
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
|
||||
this.name = 'TypertGatewayError'
|
||||
this.code = code
|
||||
this.endpoint = endpoint
|
||||
this.field = options.field
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
* @typert service typertGateway
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active TypeRT registry.
|
||||
* @param ctx - owning Host Context with TypeRT registry access.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typertGateway')
|
||||
ctx.on('internal/service', () => {
|
||||
this.srcClaims = undefined
|
||||
})
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
connectionCtx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => this.claimsEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private claimsEndpoint(endpoint: string): boolean {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
|
||||
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
|
||||
this.srcClaims ??= this.collectSrcClaims()
|
||||
return this.srcClaims.has(endpoint)
|
||||
}
|
||||
|
||||
private collectSrcClaims(): ReadonlySet<string> {
|
||||
const claims = new Set<string>()
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const binding = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
|
||||
const namespace = Reflect.get(binding, 'namespace') as string
|
||||
for (const candidate of remoteMethods(original)) {
|
||||
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
if (typeof method !== 'function') {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const strict = this.ctx.typert.local.get(endpoint)
|
||||
if (strict !== undefined) return strict
|
||||
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
||||
throw new TypertGatewayError(
|
||||
'definition-unavailable',
|
||||
endpoint,
|
||||
'its strict definition was withdrawn and SRC fallback is forbidden',
|
||||
)
|
||||
}
|
||||
return this.resolveSrcDescriptor(namespace, method, endpoint)
|
||||
}
|
||||
|
||||
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const candidates: InvocationDescriptor[] = []
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) continue
|
||||
const binding = readBinding(value, original, serviceKey, endpoint)
|
||||
if (binding.namespace !== namespace) continue
|
||||
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
|
||||
if (marker === undefined) continue
|
||||
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'ambiguous-endpoint',
|
||||
endpoint,
|
||||
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
|
||||
)
|
||||
}
|
||||
return candidates[0] as InvocationDescriptor
|
||||
}
|
||||
|
||||
private srcDescriptor(
|
||||
binding: TypeRTGatewayBinding,
|
||||
marker: ReturnType<typeof remoteMethods>[number],
|
||||
method: string,
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const signalIndex = names.indexOf('signal')
|
||||
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
'SRC cancellation parameter signal must be the final parameter',
|
||||
{ field: 'signal' },
|
||||
)
|
||||
}
|
||||
const cancellation = signalIndex >= 0
|
||||
? { parameter: 'signal' as const }
|
||||
: undefined
|
||||
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of businessNames) {
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
|
||||
{ field: name },
|
||||
)
|
||||
}
|
||||
const match = matches[0]
|
||||
const parameter: InvocationParameterDescriptor = match === undefined
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
parameters.push(parameter)
|
||||
}
|
||||
|
||||
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
|
||||
if (marker.invocation.kind === 'context') {
|
||||
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (wires.has(provider.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
|
||||
{ field: provider.wire },
|
||||
)
|
||||
}
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: marker.invocation.context,
|
||||
wire: provider.wire,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `src:${binding.serviceKey}#${endpoint}`,
|
||||
service: binding.serviceKey,
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<Context> {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (provider.wire !== invocation.wire
|
||||
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = await provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} failed`,
|
||||
{ cause, field: invocation.wire },
|
||||
)
|
||||
}
|
||||
if (context === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-not-found',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private async resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<unknown> {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
const provider = this.ctx.typert.lookups.get(key)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} is unavailable`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (provider.wire !== parameter.wire
|
||||
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = await provider.resolve(value)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} failed`,
|
||||
{ cause, field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-not-found',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
function validateBinding(
|
||||
receiver: object,
|
||||
serviceKey: string,
|
||||
namespace: string,
|
||||
endpoint: string,
|
||||
): ResolvedBinding {
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
function readBinding(
|
||||
value: unknown,
|
||||
original: object,
|
||||
serviceKey: string,
|
||||
endpoint: string,
|
||||
namespace?: string,
|
||||
): TypeRTGatewayBinding {
|
||||
if (!isObject(value)
|
||||
|| Reflect.get(value, 'service') !== original
|
||||
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
||||
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
||||
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return value as unknown as TypeRTGatewayBinding
|
||||
}
|
||||
|
||||
function originalOf(receiver: object): object {
|
||||
const original = Reflect.get(receiver, symbols.original) as unknown
|
||||
return isObject(original) ? original : receiver
|
||||
}
|
||||
|
||||
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
|
||||
let prototype: object | null = Object.getPrototypeOf(service) as object | null
|
||||
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
|
||||
while (prototype !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
|
||||
if (descriptor !== undefined) {
|
||||
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
||||
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
|
||||
}
|
||||
break
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype) as object | null
|
||||
}
|
||||
if (implementation === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`Remote marker has no prototype method ${JSON.stringify(method)}`,
|
||||
)
|
||||
}
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
names.add(part)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function invalidSignature(endpoint: string, method: string): never {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
|
||||
)
|
||||
}
|
||||
|
||||
function assertExactArguments(
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
endpoint: string,
|
||||
): void {
|
||||
if (!isPlainObject(args)) {
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
|
||||
}
|
||||
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
|
||||
}
|
||||
|
||||
function decode(
|
||||
codec: TypeRTCodec,
|
||||
value: unknown,
|
||||
code: 'input-invalid' | 'result-invalid',
|
||||
endpoint: string,
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
code,
|
||||
endpoint,
|
||||
code === 'input-invalid'
|
||||
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
||||
: 'business result failed boundary validation',
|
||||
{ cause, field },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return
|
||||
throw new TypeError('non-finite number is not JSON-safe')
|
||||
}
|
||||
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
|
||||
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
||||
throw new TypeError('sparse or decorated array is not JSON-safe')
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
|
||||
assertJsonValue(value[index], ancestors)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
assertJsonValue(descriptor.value, ancestors)
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
if (Array.isArray(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value) as object | null
|
||||
return prototype === null || prototype === Object.prototype
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
export default TypertGatewayService
|
||||
30
packages/api/gateway/src/invariant.ts
Normal file
30
packages/api/gateway/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`.
|
||||
* @module @deepseek-ai/dsh-api-gateway/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-gateway-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
54
packages/api/gateway/src/types.ts
Normal file
54
packages/api/gateway/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Carrier-independent TypeRT Gateway request, service, and error contracts.
|
||||
* @module @deepseek-ai/dsh-api-gateway/types
|
||||
*/
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
readonly namespace: string
|
||||
/** Exported Service method name. */
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
| 'arguments-invalid'
|
||||
| 'binding-invalid'
|
||||
| 'context-failed'
|
||||
| 'context-not-found'
|
||||
| 'context-unavailable'
|
||||
| 'definition-unavailable'
|
||||
| 'input-invalid'
|
||||
| 'invocation-unavailable'
|
||||
| 'lookup-failed'
|
||||
| 'lookup-not-found'
|
||||
| 'lookup-unavailable'
|
||||
| 'method-unavailable'
|
||||
| 'provider-mismatch'
|
||||
| 'result-invalid'
|
||||
| 'service-unavailable'
|
||||
| 'signature-invalid'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
}
|
||||
}
|
||||
573
packages/api/gateway/tests/client.spec.ts
Normal file
573
packages/api/gateway/tests/client.spec.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteScopeApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'fixture:goals/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const businessGoals = { owner: 'host business service' }
|
||||
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
const retained = ctx.remote.goals.create
|
||||
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.remote.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
const cancellation = new Error('caller cancelled')
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBe(businessGoals)
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
disposeBusinessGoals()
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
|
||||
})).rejects.toThrow('conflicts with the Remote service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).rejects.toThrow('repeats direct method')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).rejects.toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).rejects.toThrow('direct method goals/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).rejects.toThrow('scoped method goals/rename is already mounted')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('remote.goals') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
|
||||
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).rejects.toThrow('conflicts with an existing Remote namespace')
|
||||
await disposeRemoteTypert()
|
||||
|
||||
const disposeMultipleScoped = await ctx.remote.$mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'archive') throw new Error('fixture later-descriptor failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.rejects.toThrow('fixture later-descriptor failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('rolls back a direct projection when its scoped projection fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const disposeContext = await ctx.remote.$mount({
|
||||
package: '@fixture/context-anchor',
|
||||
descriptors: [contextDescriptor()],
|
||||
})
|
||||
const namespace = ctx.get('remote.goals') as unknown as {
|
||||
installScoped: (...args: unknown[]) => void
|
||||
readonly create?: unknown
|
||||
}
|
||||
const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
|
||||
throw new Error('fixture scoped projection failure')
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-projection-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture scoped projection failure')
|
||||
} finally {
|
||||
installScoped.mockRestore()
|
||||
}
|
||||
|
||||
expect(namespace.create).toBeUndefined()
|
||||
await disposeContext()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: direct.parameters.map((parameter, index) => index === 0
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-context',
|
||||
descriptors: [{
|
||||
...context,
|
||||
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
|
||||
} as InvocationDescriptor],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: [...direct.parameters, {
|
||||
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = await ctx.remote.$mount({
|
||||
package: '@fixture/goals',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).remote.goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
|
||||
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a method obtained from a withdrawn namespace getter', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
const namespace = ctx.get('remote.goals') as unknown as object
|
||||
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(getWithdrawn).toBeTypeOf('function')
|
||||
const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise<unknown>
|
||||
expect(() => withdrawn('agent-1', { objective: 'ship' }))
|
||||
.toThrow('Remote method is no longer mounted')
|
||||
})
|
||||
|
||||
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...base } = directDescriptor()
|
||||
const descriptor: InvocationDescriptor = {
|
||||
...base,
|
||||
id: '@fixture/goals#goals/prototype',
|
||||
method: 'prototype',
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: '__proto__',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
|
||||
}],
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
|
||||
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
|
||||
expect(Object.getPrototypeOf(payload.args)).toBeNull()
|
||||
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
|
||||
expect(payload.args.__proto__).toBe('wire-value')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when namespace Service startup fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.rejects.toThrow('fixture namespace startup failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh direct namespace when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'create') throw new Error('fixture direct method installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'rename') throw new Error('fixture scoped installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.rejects.toThrow('fixture scoped installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('remote.goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
|
||||
expect(ctx.get('remote.goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
packages/api/gateway/tsconfig.json
Normal file
27
packages/api/gateway/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/api/gateway/tsdown.config.ts
Normal file
3
packages/api/gateway/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/api/remotes/README.i18n.yaml
Normal file
6
packages/api/remotes/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
|
||||
33
packages/api/remotes/README.md
Normal file
33
packages/api/remotes/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
|
||||
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
|
||||
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.
|
||||
33
packages/api/remotes/README.zh.md
Normal file
33
packages/api/remotes/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
|
||||
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
|
||||
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。
|
||||
64
packages/api/remotes/package.json
Normal file
64
packages/api/remotes/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
211
packages/api/remotes/src/agent-lookup.ts
Normal file
211
packages/api/remotes/src/agent-lookup.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
|
||||
export type ApiRemoteLookupError =
|
||||
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
|
||||
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
|
||||
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
|
||||
|
||||
/** Result of resolving one session identity to its live Agent. */
|
||||
export type ApiRemoteAgentResult =
|
||||
| { readonly agent: Agent }
|
||||
| { readonly error: ApiRemoteLookupError }
|
||||
|
||||
/** Resume configuration supplied by the owning Host composition. */
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Read the per-Agent defaults when a cold identity must resume. */
|
||||
readonly agentOptions?: () => AgentOptions
|
||||
/**
|
||||
* Build the Host-specific Agent-scope composition completed before
|
||||
* publication. Keyed by the resumed session itself because what a Host
|
||||
* installs may depend on what that session recorded: an agent preset fixes
|
||||
* the tools its history was produced under, so rebuilding it under another
|
||||
* composition would replay tool calls the agent can no longer make. The
|
||||
* events come along because a session's own record of such a choice may be
|
||||
* an event rather than a header field.
|
||||
* @param session - the resumed session's persisted header and event log.
|
||||
* @returns the Agent-scope setup to run before publication.
|
||||
*/
|
||||
readonly setup?: (
|
||||
session: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
) => AgentSetup | Promise<AgentSetup>
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
export class ApiRemoteSessionNotFound extends Error {}
|
||||
|
||||
/** Session identity whose lifecycle belongs to subagent routing. */
|
||||
export class ApiRemoteSubagentSessionOwnership extends Error {
|
||||
/**
|
||||
* Construct the ownership fence.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
*/
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether generic Host routing must leave an identity to subagent routing.
|
||||
* @param ctx - Host Context carrying the live Agent registry.
|
||||
* @param session - attached or live Session metadata.
|
||||
* @param agent - live Agent when one is registered.
|
||||
* @returns whether generic Remote and legacy API calls must reject the identity.
|
||||
*/
|
||||
export function hasApiRemoteSubagentOwner(
|
||||
ctx: Context,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable caller-facing ownership rejection.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
* @returns the existing `agent-busy` RPC shape.
|
||||
*/
|
||||
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
|
||||
return {
|
||||
code: 'agent-busy',
|
||||
message: `session "${sessionId}" is owned by subagent routing`,
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold served session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host Context carrying the optional persistence provider.
|
||||
* @param sessionId - durable identity to inspect.
|
||||
* @returns detached metadata and events for a servable session.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
|
||||
*/
|
||||
export async function inspectApiRemoteSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
const inspected = await persistence.inspect(sessionId)
|
||||
if (inspected.meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return { meta: inspected.meta, events: [...inspected.events] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
|
||||
* Live Agents are reused, ordinary cold sessions resume once per identity, and
|
||||
* subagent-owned identities retain the legacy `agent-busy` fence.
|
||||
* @param ctx - owning Host Context.
|
||||
* @param options - defaults and Agent-scope setup used only for cold resume.
|
||||
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
|
||||
*/
|
||||
export function createApiRemoteAgentResolver(
|
||||
ctx: Context,
|
||||
options: ApiRemoteAgentOptions,
|
||||
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live === undefined) return undefined
|
||||
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return { agent: live }
|
||||
}
|
||||
|
||||
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
let resume = resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectApiRemoteSession(ctx, sessionId)
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
// Built from the inspected session before the published re-checks
|
||||
// below, so those stay adjacent to `resume` and a Host setup that
|
||||
// awaits (composing a preset, say) does not widen the collision
|
||||
// window.
|
||||
const setup = options.setup === undefined ? undefined : await options.setup(inspected)
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
||||
...setup === undefined ? {} : { setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
}
|
||||
})()
|
||||
resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRemoteSessionNotFound) {
|
||||
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
||||
}
|
||||
if (error instanceof ApiRemoteSubagentSessionOwnership) {
|
||||
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
|
||||
}
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
|
||||
})
|
||||
|
||||
return agentFor
|
||||
}
|
||||
27
packages/api/remotes/src/client/index.ts
Normal file
27
packages/api/remotes/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by this Client assembly. */
|
||||
remote: TypeRTClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required service: the typed Client Remote contribution mount. */
|
||||
export const inject = ['remote']
|
||||
|
||||
/**
|
||||
* Mount the Host capabilities explicitly selected for this Client assembly.
|
||||
* @param ctx - Client Cordis root carrying the typed API service.
|
||||
* @returns disposer after every selected Remote namespace is ready.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await ctx.remote.$mount(goalsRemote)
|
||||
}
|
||||
18
packages/api/remotes/src/index.ts
Normal file
18
packages/api/remotes/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
inspectApiRemoteSession,
|
||||
} from './agent-lookup.ts'
|
||||
export type {
|
||||
ApiRemoteAgentOptions,
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
24
packages/api/remotes/src/invariant.ts
Normal file
24
packages/api/remotes/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
function header(id: SessionId): SessionHeader {
|
||||
return { version: 0, id, createdAt: 1, cwd: '/proj' }
|
||||
}
|
||||
|
||||
async function createContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function provideSession(
|
||||
ctx: Context,
|
||||
meta: SessionHeader,
|
||||
inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>,
|
||||
): void {
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
}
|
||||
|
||||
describe('API Remote Agent resolver races', () => {
|
||||
it('maps an inspected session without a cwd to session-not-found', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('missing-after-inspect')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({
|
||||
meta: { ...meta, cwd: undefined } as unknown as SessionHeader,
|
||||
events: [],
|
||||
}))
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resumes through a concurrently attached ordinary Session without optional defaults', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('ordinary-attach-race')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ agent: { id: sessionId } })
|
||||
expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a subagent Session published after durable inspection', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('owned-attach-race')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => {
|
||||
ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reclassifies failed resumes after a live or attached subagent wins publication', async () => {
|
||||
for (const winner of ['agent', 'session'] as const) {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid(`owned-${winner}-resume-race`)
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] }))
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session))
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-cold-resume')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const agentCtx = ctx.extend()
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the subagent ownership fence to the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-owned-subagent')
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
ctx.agents.register(stubAgent(ctx.extend(), session))
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
const resolution = provider.resolve(sessionId)
|
||||
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Built-artifact smoke for the first generated Remote: plain Node boots the
|
||||
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const root = resolve(packageDir, '../../..')
|
||||
const artifact = (path: string): string => join(root, path)
|
||||
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
|
||||
|
||||
const requiredArtifacts = [
|
||||
'packages/client/connection/lib/client.js',
|
||||
'packages/client/connection/lib/index.js',
|
||||
'packages/api/remotes/lib/client.js',
|
||||
'packages/core/agent/lib/index.js',
|
||||
'packages/core/session/lib/index.js',
|
||||
'packages/goal/goal/lib/index.js',
|
||||
'packages/goal/goal/lib/typert.host.js',
|
||||
'packages/api/gateway/lib/client.js',
|
||||
'packages/api/gateway/lib/index.js',
|
||||
'packages/typert/registry/lib/client.js',
|
||||
'packages/typert/registry/lib/index.js',
|
||||
].every(path => existsSync(artifact(path)))
|
||||
|
||||
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
|
||||
const urls = Object.fromEntries(Object.entries({
|
||||
agent: 'packages/core/agent/lib/index.js',
|
||||
apiGatewayClient: 'packages/api/gateway/lib/client.js',
|
||||
apiGatewayHost: 'packages/api/gateway/lib/index.js',
|
||||
connectionClient: 'packages/client/connection/lib/client.js',
|
||||
connectionHost: 'packages/client/connection/lib/index.js',
|
||||
goal: 'packages/goal/goal/lib/index.js',
|
||||
goalTypert: 'packages/goal/goal/lib/typert.host.js',
|
||||
registryClient: 'packages/typert/registry/lib/client.js',
|
||||
registryHost: 'packages/typert/registry/lib/index.js',
|
||||
remotesClient: 'packages/api/remotes/lib/client.js',
|
||||
session: 'packages/core/session/lib/index.js',
|
||||
}).map(([key, path]) => [key, artifactUrl(path)]))
|
||||
const script = `
|
||||
import { createServer } from 'node:http'
|
||||
import * as cordis from 'cordis'
|
||||
|
||||
const urls = ${JSON.stringify(urls)}
|
||||
const { Context } = cordis
|
||||
const { default: AgentRegistry } = await import(urls.agent)
|
||||
const connectionHost = await import(urls.connectionHost)
|
||||
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
|
||||
const { default: GoalService } = await import(urls.goal)
|
||||
const { TYPERT } = await import(urls.goalTypert)
|
||||
const { default: TypertRegistry } = await import(urls.registryHost)
|
||||
const { Session, SessionId } = await import(urls.session)
|
||||
|
||||
const routes = []
|
||||
const host = new Context()
|
||||
host.provide('httpServer', {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex() { return () => {} },
|
||||
port: 0,
|
||||
})
|
||||
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
|
||||
await host.plugin(TypertRegistry)
|
||||
await host.plugin(AgentRegistry)
|
||||
await host.plugin(TypertGatewayService)
|
||||
await host.plugin(GoalService)
|
||||
host.typert.register(TYPERT)
|
||||
|
||||
const makeAgent = rawId => {
|
||||
const session = new Session(SessionId(rawId))
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: host.extend(),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send() {},
|
||||
updateInbox() { return 'not-found' },
|
||||
followup() {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
|
||||
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
|
||||
reserveTurnAdmission() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
const rootAgent = makeAgent('built-root-agent')
|
||||
const scopedAgent = makeAgent('built-scoped-agent')
|
||||
host.agents.register(rootAgent)
|
||||
host.agents.register(scopedAgent)
|
||||
|
||||
if (routes.length !== 1 || routes[0].path !== '/api') {
|
||||
throw new Error('Connection did not register exactly one /api route')
|
||||
}
|
||||
const server = createServer((request, response) => { void routes[0].handler(request, response) })
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
|
||||
const origin = 'http://127.0.0.1:' + String(address.port)
|
||||
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === 'cordis') return cordis
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
|
||||
let invalidRejected = false
|
||||
try {
|
||||
await client.remote.goals.create(rootAgent.id, { objective: 1 })
|
||||
} catch {
|
||||
invalidRejected = true
|
||||
}
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidRejected,
|
||||
rootResult,
|
||||
rootEdit,
|
||||
scopedResult,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.events.length,
|
||||
scopedEvents: scopedAgent.session.events.length,
|
||||
}
|
||||
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
|
||||
const result = await runPlainNode(script)
|
||||
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
|
||||
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
|
||||
invalidRejected: boolean
|
||||
rootResult: { ref: { id: string; revision: number } }
|
||||
rootEdit: { objective: string; revision: number }
|
||||
scopedResult: { ref: { id: string; revision: number } }
|
||||
rootGoal: string
|
||||
scopedGoal: string
|
||||
rootEvents: number
|
||||
scopedEvents: number
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
invalidRejected: true,
|
||||
rootResult: { ref: { revision: 1 } },
|
||||
rootEdit: { objective: 'edited root goal', revision: 2 },
|
||||
scopedResult: { ref: { revision: 1 } },
|
||||
rootGoal: 'edited root goal',
|
||||
scopedGoal: 'scoped goal',
|
||||
rootEvents: 2,
|
||||
scopedEvents: 1,
|
||||
})
|
||||
expect(output.rootResult.ref.id).toMatch(/^goal-/)
|
||||
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Execute one ESM script without tsx or a TypeScript loader. */
|
||||
function runPlainNode(script: string): Promise<{
|
||||
readonly exitCode: number | null
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
}> {
|
||||
return new Promise((resolveRun) => {
|
||||
execFile(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 55_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolveRun({
|
||||
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
22
packages/api/remotes/tsconfig.client.json
Normal file
22
packages/api/remotes/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/api/remotes/tsconfig.host.json
Normal file
36
packages/api/remotes/tsconfig.host.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
packages/api/remotes/tsconfig.json
Normal file
11
packages/api/remotes/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/api/remotes/tsdown.config.ts
Normal file
7
packages/api/remotes/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
{ hostPhase: true },
|
||||
)
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/README.md
|
||||
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
|
||||
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
|
||||
README.md: edffdd67a982a885b7e3e537be77c8971f5b697b
|
||||
README.zh.md: 8cb56f38df4999cb2055b9e2808ce092bc7a8282
|
||||
|
||||
@@ -6,7 +6,7 @@ The capability family spans the canonical executor seam, its implementations, th
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by Service providers and Consumers. | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
|
||||
@@ -15,3 +15,5 @@ The capability family spans the canonical executor seam, its implementations, th
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
|
||||
|
||||
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
|
||||
|
||||
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/bash.md](../../docs/subsystems/bash.md).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` |
|
||||
| [`bash/`](bash/README.md) | 定义 Service provider 与 Consumer 共享的执行器约定。 | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash`) |
|
||||
@@ -15,3 +15,5 @@
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) |
|
||||
|
||||
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。
|
||||
|
||||
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/bash.md](../../docs/subsystems/bash.md)。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
|
||||
README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。
|
||||
|
||||
包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。
|
||||
包根导出 Cordis 插件约定(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费方在加载本插件后使用 `ctx.bashEnv`。
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
rootCallId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
|
||||
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
|
||||
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
|
||||
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
Local Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 Service provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。
|
||||
|
||||
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* Local Service provider for the bash capability seam over the subprocess
|
||||
* capability seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
|
||||
* explicit argv. This executor owns command defaulting, deadlines and cause
|
||||
* classification, the model-friendly terminal environment, and the model-facing
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
|
||||
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
|
||||
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
|
||||
README.md: 44321d7ef26e9e4399438f61b3fdfbfa2e4d8c11
|
||||
README.zh.md: f3e110c049871a5a6121716307aaa3d1d1f3eedb
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
Sandbox-consuming Service provider for the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`. A present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -70,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names argv[0] remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -84,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
|
||||
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a synchronous `SubprocessService` throw that names the runner path instead fails `start()` immediately.
|
||||
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam Service provider。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用,Node 必须报告 `ENOENT` 或 `EACCES`,并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样能指明 runner 的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升权引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT`/`EACCES` 的 `path` 或 `syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -84,5 +84,5 @@
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;`SubprocessService` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"node-addon-landlock-run": "0.0.0-test.0"
|
||||
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function isUsableWorkdir(path: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
|
||||
@@ -44,7 +44,7 @@ export type Config = LocalConfig
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) is owned by
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
@@ -124,7 +124,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
|
||||
// LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
@@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
|
||||
* rung forced off, so the workspace `landlock-run` launcher confines) underneath the
|
||||
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
|
||||
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
|
||||
* facts; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock; the
|
||||
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
|
||||
* Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from
|
||||
* `native/landlock-run` before running this file.
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -151,7 +151,7 @@ describe('partial Landlock runner-failure classification', () => {
|
||||
|
||||
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
|
||||
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
|
||||
// provenanced ENOENT/EACCES evidence required for runner attribution.
|
||||
// ENOENT/EACCES with the exact failed executable path required for runner attribution.
|
||||
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('fail closed', () => {
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
|
||||
it('classifies a synchronous SubprocessService EACCES with the exact runner path', async () => {
|
||||
const runner = join(spillDir, 'unexecutable-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
@@ -439,7 +439,7 @@ describe('isRunnerSpawnFailure', () => {
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only syscall provenance compatible with the exact runner program', () => {
|
||||
it('accepts only syscall and error-path facts that identify the exact runner program', () => {
|
||||
const runner = join(spillDir, 'runner with spaces')
|
||||
const spawnError = (syscall: string, path?: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../native/landlock-run/packages/entry"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
|
||||
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
The **`BashExecutor`** (`ctx.bash`) defines WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
This package owns the Service Definition role of the bash capability, split so each role can evolve (and be swapped) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-bash` (this) | Service Definition: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | Service provider: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | Service provider: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
|
||||
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface; the consumer detects its `sandboxMode` capability and adds escalation fields without importing the implementation. A containerized or remote executor slots in the same way.
|
||||
The split is a standard capability seam ([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): `dsh-bash-sandbox` is a sandboxing executor behind the same Service Definition — the Consumer detects its `sandboxMode` capability and adds escalation fields without importing the provider — and a containerized or remote executor slots in the same way.
|
||||
|
||||
## Service API (`ctx.bash`)
|
||||
|
||||
@@ -31,11 +31,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
**`BashExecutor`**(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
本包承担 bash 能力的 Service Definition 角色,各角色因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-bash`(本包) | Service Definition:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | Service provider:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | Service provider:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
|
||||
|
||||
该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
该拆分是一个标准的能力 seam([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。
|
||||
|
||||
## 服务 API(`ctx.bash`)
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The `ctx.bash` executor seam for foreground commands and background process
|
||||
* Service Definition for the `ctx.bash` capability seam, covering foreground commands and background process
|
||||
* handles. Task ids, ownership, polling, and notices belong to
|
||||
* `@deepseek-ai/dsh-tasks`, keeping executors independent of sessions.
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
|
||||
@@ -10,7 +10,7 @@ export const name = 'bash-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless seam owns request/result types, while executors and policy own observations. */
|
||||
/** No runtime invariant: this stateless Service Definition owns request/result types, while executors and policy own observations. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
|
||||
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
|
||||
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
|
||||
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
|
||||
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
Local PowerShell Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
|
||||
@@ -23,7 +23,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
## Behavior
|
||||
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell Service provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
|
||||
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## 行为(及其由来)
|
||||
## 行为
|
||||
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* Local PowerShell Service provider for the bash capability seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
@@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
/**
|
||||
* The pwsh invocation argv for one resolved spec — the argv-level seam a
|
||||
* confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
|
||||
* `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see
|
||||
* `@deepseek-ai/dsh-pwsh-sandbox`).
|
||||
*/
|
||||
protected argv(spec: BashExecSpec): string[] {
|
||||
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
|
||||
}
|
||||
|
||||
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
argv: readonly string[],
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
argv: [...argv],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return this.runArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Foreground run of an exact argv (the confining subclass re-wraps it). */
|
||||
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
@@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return this.startArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Background start of an exact argv (the confining subclass re-wraps it). */
|
||||
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
@@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the declared seam for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the
|
||||
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
* @param _spawnFailed - whether the spawn rejected before any process existed.
|
||||
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* writes CRLF on Windows, so exact text assertions normalize line endings.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
||||
const norm = (value: string) => (
|
||||
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value
|
||||
)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
@@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
|
||||
it('falls through an empty configured path to platform resolution', () => {
|
||||
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
|
||||
// fallback candidate cannot exist either.
|
||||
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
|
||||
expect(resolvePwshPath('', {
|
||||
PATH: 'P:\\Store',
|
||||
ProgramFiles: 'P:\\no-program-files',
|
||||
SystemRoot: 'S:\\no-windows',
|
||||
}, 'win32')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
|
||||
@@ -80,6 +86,13 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
|
||||
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('uses stable Windows roots when the environment omits both overrides', () => {
|
||||
expect(candidatePwshPaths({})).toEqual([
|
||||
join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
})
|
||||
|
||||
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
|
||||
const candidates = candidatePwshPaths({
|
||||
ProgramFiles: 'P:\\Program Files',
|
||||
@@ -119,6 +132,8 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
override async resolveExecutable(command: string): Promise<string> { return command }
|
||||
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
@@ -152,12 +167,12 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
it('resolves with output and the effective timeout', { timeout: 15_000 }, async () => {
|
||||
const { bash } = await setup({ timeoutMs: 10_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(lf(result.stdout.text)).toBe('hi\n')
|
||||
expect(result.timeoutMs).toBe(5_000)
|
||||
expect(result.timeoutMs).toBe(10_000)
|
||||
})
|
||||
|
||||
it('uses config cwd, overridable per call', async () => {
|
||||
@@ -299,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
await proc.done
|
||||
const output = partialOutput + lf(proc.readOutput().delta)
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/cli-demo/README.md
|
||||
README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8
|
||||
README.zh.md: b032023fee4bf9d992217cc51731f6356f875daf
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md
|
||||
README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2
|
||||
README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec
|
||||
34
packages/bash/pwsh-sandbox/README.md
Normal file
34
packages/bash/pwsh-sandbox/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt.
|
||||
|
||||
The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy).
|
||||
|
||||
## Behavior
|
||||
|
||||
- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`.
|
||||
- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Confinement works, denial surfaces as command failure
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; the denial surface belongs to the tool layer.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap.
|
||||
- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package).
|
||||
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。
|
||||
|
||||
执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。
|
||||
|
||||
## 行为
|
||||
|
||||
- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。
|
||||
- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 隔离生效,拒绝以命令失败呈现
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;拒绝呈现面属于工具层。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。
|
||||
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。
|
||||
45
packages/bash/pwsh-sandbox/package.json
Normal file
45
packages/bash/pwsh-sandbox/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pwsh-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Internal sandbox-result classification helpers — deliberate call-for-call
|
||||
* mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of
|
||||
* the bash consumer shares the identical classification dialect).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/helpers
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive denial substrings from the active wrap.
|
||||
* @returns whether the failed run matches that denial dialect.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Sandbox-consuming PowerShell executor — the pwsh twin of
|
||||
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
|
||||
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
|
||||
* chain), inherits local process mechanics, and reports the selected mode,
|
||||
* enforcement, and denial facts. Positive runner-launch evidence means the
|
||||
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool layer owns the escalation approval flow
|
||||
* through `ctx.approval`; this executor reports the sandbox facts the tool
|
||||
* renders.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and fallback `workspace-write` root — is NOT here: it lives
|
||||
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
|
||||
* each calling session's mode and cwd for every enforcing capability. The
|
||||
* runner choice is likewise the `ctx.sandbox` provider's config, not this
|
||||
* executor's.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local pwsh executor and requires a
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
|
||||
* sandbox denial rendering and escalation surface (see the
|
||||
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
|
||||
* resolved policy; direct calls fall back to deployment policy.
|
||||
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
|
||||
* renders.
|
||||
*/
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
|
||||
export class SandboxPwshExecutor extends PwshLocalExecutor {
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
/**
|
||||
* Per-process confinement facts retained until settlement. Providers may
|
||||
* vary enforcement and diagnostic dialect between overlapping calls, so a
|
||||
* shared latest-wrap value would classify a process against the wrong facts.
|
||||
* Unconfined processes have no entry.
|
||||
*/
|
||||
private readonly processFacts = new Map<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// The default mode is the capability fact used for schema advertisement;
|
||||
// actual tool executions carry their resolved per-call policy.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a complete per-call policy onto the spec. Tool calls supply the
|
||||
* calling session's resolved mode and root; lower-level callers fall back to
|
||||
* the deployment policy.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access
|
||||
* processes have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param spec - resolved execution spec whose pwsh argv is confined.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(this.argv(spec), policy)
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export default SandboxPwshExecutor
|
||||
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-sandbox-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or
|
||||
* mutable data relation beyond contracts enforced at its owning seams.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
111
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
111
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
|
||||
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
|
||||
* REAL pwsh spawns confined through the runner — the debug-instance
|
||||
* verification of both modes: read-only denies every write (not even NUL),
|
||||
* workspace-write allows the workspace and temp while denying escape writes,
|
||||
* and denial/classification facts ride the settled result.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let executor!: SandboxPwshExecutor
|
||||
|
||||
beforeAll(async () => {
|
||||
// The escape probe must live OUTSIDE every legitimately granted tree: the
|
||||
// provider's workspace-write grants the workspace plus the REAL temp dir
|
||||
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
|
||||
// scratch dir under temp would inherit the grant and the probe would be a
|
||||
// false pass. A mkdtemp under the profile is removed by afterAll.
|
||||
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxPwshExecutor, {})
|
||||
executor = ctx.bash as SandboxPwshExecutor
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
|
||||
// A self-caught denial keeps the command exit 0: no denial fact.
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
|
||||
// A raw failing write must classify as a denial of the ACL dialect.
|
||||
const denied = await executor.run(executor.resolve({
|
||||
command: `Set-Content -Path '${escapeFile}' -Value x`,
|
||||
sandboxPolicy: policy,
|
||||
}))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 60_000)
|
||||
|
||||
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
}, 60_000)
|
||||
})
|
||||
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service
|
||||
* makes wrapping, policy hand-off, fail-closed propagation, and fact stamping
|
||||
* deterministic; real-provider integration lives in `tests/acl.e2e.ts`.
|
||||
* Requires pwsh for the integration block (skips without it — same gate as
|
||||
* pwsh-local's suites); the helpers block is pure and always runs.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
|
||||
|
||||
// The same probe pwsh-local's suites and the vitest coverage exemption use:
|
||||
// spawnSync never throws on a missing binary (it reports status null), and
|
||||
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] })
|
||||
|
||||
/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */
|
||||
function throwingSubprocessService(error: unknown): new (ctx: Context) => Service {
|
||||
return class extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subprocess')
|
||||
}
|
||||
|
||||
spawn(): never {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(
|
||||
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
|
||||
subprocess: new (ctx: Context) => Service = LocalSubprocessService,
|
||||
): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
calls.push({ argv: [...argv], policy })
|
||||
return behavior(argv, policy)
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
|
||||
await ctx.plugin(subprocess)
|
||||
if (ctx.subprocess instanceof LocalSubprocessService) {
|
||||
ctx.subprocess.internals = { spillDir }
|
||||
}
|
||||
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
|
||||
return { executor: ctx.bash as SandboxPwshExecutor, calls }
|
||||
}
|
||||
|
||||
describe('helpers (pure)', () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-'))
|
||||
afterAll(() => {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
const absolute = process.execPath
|
||||
const bare = 'node'
|
||||
const relative = './sandbox-runner'
|
||||
|
||||
it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => {
|
||||
for (const runnerProgram of [absolute, bare, relative]) {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false)
|
||||
// An existing FILE (not a directory) workdir is unusable without throwing.
|
||||
const fileWorkdir = join(workdir, 'a-file')
|
||||
writeFileSync(fileWorkdir, 'x')
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
const rules: readonly RunnerFailureRule[] = [{
|
||||
allowedExitCodes: [127],
|
||||
fatalSignatures: ['fake-runner: '],
|
||||
informationalLines: ['fake-runner: partial enforcement'],
|
||||
}]
|
||||
|
||||
it('matches a fatal signature on a gated exit code, skipping informational lines', () => {
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules))
|
||||
.toEqual({ detail: 'fake-runner: profile refused' })
|
||||
})
|
||||
|
||||
it('rejects zero/null exits, gate mismatches, and empty signatures', () => {
|
||||
expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
|
||||
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
|
||||
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
|
||||
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesSignature', () => {
|
||||
it('matches non-zero exits case-insensitively, never zero or signal exits', () => {
|
||||
expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true)
|
||||
expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true)
|
||||
expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
|
||||
// Denial device for the POSIX classification cases: a mode-0555 directory
|
||||
// INSIDE a temp scratch tree (the same device as bash-sandbox's suites) —
|
||||
// unit tests never attempt writes outside the system temp directory. On
|
||||
// win32 there is no POSIX mode denial; the real-sandbox denial coverage
|
||||
// lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths.
|
||||
const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-'))
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555)
|
||||
const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')`
|
||||
|
||||
afterAll(() => {
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755)
|
||||
rmSync(readOnlyDir, { recursive: true, force: true })
|
||||
rmSync(spillDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
|
||||
it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]
|
||||
expect(call?.policy).toEqual(RO)
|
||||
// The confined argv is the pwsh invocation, ready for a runner prefix.
|
||||
expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u)
|
||||
expect(call?.argv).toContain('-NonInteractive')
|
||||
expect(call?.argv.at(-1)).toContain('echo wrapped')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
expect(executor.sandboxMode).toBe('workspace-write')
|
||||
const result = await executor.run(executor.resolve({ command: 'echo fallback' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls[0]?.policy.mode).toBe('workspace-write')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
}, 30_000)
|
||||
|
||||
it('an aborted caller signal outranks runner-spawn attribution', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller-cancel')
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal })))
|
||||
.rejects.toThrow('caller-cancel')
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the
|
||||
// real-sandbox denial classification is covered by tests/acl.e2e.ts
|
||||
// (the ACL runner denies scratch paths — unit tests never leave temp).
|
||||
it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => {
|
||||
const { executor } = await setup()
|
||||
const result = await executor.run(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow('sync-emfile')
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow('sync-emfile-start')
|
||||
}, 30_000)
|
||||
|
||||
it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('background confined runs stamp clean facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO }))
|
||||
await clean.done
|
||||
expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial
|
||||
// coverage lives in tests/acl.e2e.ts.
|
||||
it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const denied = executor.start(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
await denied.done
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('background spawn rejections settle as runnerFailed facts', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
|
||||
await proc.done
|
||||
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
// The failure note surfaces through the read path.
|
||||
const read = proc.readOutput()
|
||||
expect(read.delta).toContain('spawn failed')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access background runs bypass confine and carry no facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const proc = executor.start(executor.resolve({
|
||||
command: 'echo full-bg',
|
||||
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
|
||||
}))
|
||||
await proc.done
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(proc.sandbox).toBeUndefined()
|
||||
}, 30_000)
|
||||
})
|
||||
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/pwsh-local"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
|
||||
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
|
||||
README.md: 9e2cd0c2ed999a11dcbffcd99a1d0cb672367905
|
||||
README.zh.md: 8a1e7ba0af36ffd891b38491075ee75283fdbed5
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
Requires a loaded executor Service provider (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
|
||||
|
||||
@@ -24,7 +24,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
|
||||
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the Service Definition (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
@@ -42,7 +42,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
需要加载执行器 Service provider(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约定是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
包(package)根只公开 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
|
||||
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此 Service Definition(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
|
||||
### 托管 shell 环境
|
||||
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约定——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 逐会话模式切换
|
||||
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换约定](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Model-facing `bash` tool over the `ctx.bash` executor seam. Background calls
|
||||
* Model-facing Consumer of the `ctx.bash` capability seam. Background calls
|
||||
* register process handles with `ctx.tasks`; their work uses task cancellation
|
||||
* rather than the tool-call signal after an id is returned.
|
||||
*
|
||||
@@ -155,7 +155,7 @@ function resolveWorkdir(
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
||||
function canonicalBashResult(result: BashRunResult) {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
@@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval ingredients
|
||||
* The shared policy resolver is required whenever the executor advertises
|
||||
* confinement, so a split composition fails at tool-plugin load.
|
||||
* `sandbox_permissions` still reaches execute) and the approval
|
||||
* ingredients. The shared policy resolver is required whenever the executor
|
||||
* advertises confinement, so a split composition fails at tool-plugin load.
|
||||
*/
|
||||
const approveBashEscalation = (
|
||||
mode: string,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL bash tool
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* through the agent loop, exercising the same execution paths a live model would
|
||||
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
|
||||
* agent.inject completion notices).
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
|
||||
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
|
||||
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
|
||||
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
|
||||
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
|
||||
|
||||
@@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. |
|
||||
| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
@@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
|
||||
|
||||
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
@@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
|
||||
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
|
||||
|
||||
包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
包根只导出 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
|
||||
插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回 task id;不适用超时。 |
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed,不运行任何内容。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
@@ -28,9 +30,9 @@
|
||||
|
||||
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
|
||||
当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
|
||||
|
||||
@@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be
|
||||
|
||||
#### What the model sees
|
||||
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[sandbox: file access denied under <mode> mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
#### What the model sees
|
||||
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -46,12 +49,15 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// sandbox.runnerFailed to task `failed`. The current contract aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
|
||||
* Model-facing PowerShell Consumer of the `ctx.bash` capability seam. Intended for
|
||||
* Windows compositions where a PowerShell executor (e.g.
|
||||
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
|
||||
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
|
||||
*
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation mirrors the bash tool's too: a completed
|
||||
* foreground call is a terminal card with the parsed exit-status pill, using
|
||||
* the shared exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
|
||||
* `run_in_background` execution (background handles register with the
|
||||
* generic `ctx.tasks` runtime), the managed `DSH_*` environment through the
|
||||
* shared `bash-env` registry, the per-call sandbox policy resolution (the
|
||||
* calling session's mode and cwd travel to the confining executor), the
|
||||
* sandbox-denial rendering with the same-turn escalation surface
|
||||
* (`sandbox_permissions` + `justification` resolved through
|
||||
* `ctx.approval`), and the bash marker/truncation rendering story. UI
|
||||
* presentation mirrors the bash tool's too: a completed foreground call is
|
||||
* a terminal card with the parsed exit-status pill, using the shared
|
||||
* exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
@@ -19,16 +23,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { parseExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
import type { RenderablePwshResult } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -57,6 +66,8 @@ interface PwshToolArgs {
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
@@ -69,6 +80,7 @@ interface PwshForegroundResult {
|
||||
timeoutMs: number
|
||||
stdout: { text: string; truncated: boolean; spillPath?: string }
|
||||
stderr: { text: string; truncated: boolean; spillPath?: string }
|
||||
sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean }
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
|
||||
@@ -82,21 +94,54 @@ function validatePwshArgs(args: PwshToolArgs): void {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
|
||||
// the shared rule both enforcing families validate identically.
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function pwshDescription(backgroundEnabled: boolean): string {
|
||||
function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'
|
||||
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
|
||||
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
if (escalationModes.length === 0) return base
|
||||
// The CLM and named-pipe contracts below are Windows-restricted-token
|
||||
// behavior, but the gate is 'any confining executor is mounted'
|
||||
// (escalationModes non-empty). The conflation is safe today because every
|
||||
// shipped composition pairing tool-pwsh with a confining executor is
|
||||
// win32-only; a future POSIX pwsh-sandbox composition must gate both
|
||||
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
|
||||
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
|
||||
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
|
||||
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
|
||||
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
|
||||
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
|
||||
+ 'do not retry the command another way — escalate the exact command once or restructure it to '
|
||||
+ 'avoid capturing output. '
|
||||
+ 'Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
|
||||
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
|
||||
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
|
||||
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
|
||||
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
|
||||
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
|
||||
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
|
||||
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
|
||||
+ 'A rejected escalation is final for that command — stop and explain, never work around '
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +157,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
@@ -129,6 +174,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
...result.sandbox !== undefined ? {
|
||||
sandbox: {
|
||||
mode: result.sandbox.mode,
|
||||
denied: result.sandbox.denied,
|
||||
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
||||
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
||||
},
|
||||
} : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +192,55 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
} as const
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
||||
throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
|
||||
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
|
||||
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
||||
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes, delegating the shared fail-closed sequence (strict
|
||||
* widening, channel resolution, outcome mapping) to
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval
|
||||
* ingredients. The shared policy resolver is required whenever the
|
||||
* executor advertises confinement, so a split composition fails at
|
||||
* tool-plugin load.
|
||||
*/
|
||||
const approvePwshEscalation = (
|
||||
mode: string,
|
||||
justification: string,
|
||||
exec: ToolExecution,
|
||||
standingPolicy: SandboxExecutionPolicy | undefined,
|
||||
): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
|
||||
return approveEscalation(
|
||||
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
||||
{
|
||||
approver: ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName: 'pwsh',
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
@@ -151,7 +251,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(backgroundEnabled),
|
||||
description: pwshDescription(backgroundEnabled, escalationModes),
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
@@ -166,7 +267,19 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
...escalationModes.length > 0 ? {
|
||||
sandbox_permissions: {
|
||||
type: 'string' as const,
|
||||
enum: [...escalationModes],
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string' as const,
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
output: {
|
||||
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
|
||||
// consumers of one must accept the other (see the pwsh-tool-and-executor
|
||||
@@ -209,6 +322,16 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
sandbox: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: { type: 'string', required: true },
|
||||
denied: { type: 'boolean', required: true },
|
||||
enforcement: { type: 'string' },
|
||||
runnerFailed: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -218,18 +341,27 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderPwshResult(value),
|
||||
: renderPwshResult(value as RenderablePwshResult, escalationModes),
|
||||
}],
|
||||
},
|
||||
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const standingPolicy = resolveSandboxPolicy(exec)
|
||||
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
||||
: undefined
|
||||
const policy = approvedMode === undefined
|
||||
? standingPolicy
|
||||
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: ctx.bashEnv.collect(exec),
|
||||
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
@@ -241,15 +373,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
|
||||
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
|
||||
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore end */
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'pwsh',
|
||||
@@ -260,7 +388,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput()),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
|
||||
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
|
||||
* stderr section, truncation notices with spill paths, then exit-status
|
||||
* markers. Non-zero exits are reported, not errored — the model decides how to
|
||||
* react; only infrastructure failures (spawn errors, aborts) surface as
|
||||
* isError results.
|
||||
* `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox
|
||||
* denial/runner-failure markers (with the same-turn escalation hint), and
|
||||
* truncation notices with spill paths, then exit-status markers. Non-zero
|
||||
* exits are reported, not errored — the model decides how to react; only
|
||||
* infrastructure failures (spawn errors, aborts) surface as isError
|
||||
* results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/render
|
||||
*/
|
||||
|
||||
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
@@ -27,6 +30,7 @@ export interface RenderablePwshResult {
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +38,15 @@ export interface RenderablePwshResult {
|
||||
* stderr section, then exit-status markers, matching the bash tool's story —
|
||||
* a clean exit (0, no signal) produces no marker.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @param escalationModes - the escalation targets this composition advertises;
|
||||
* non-empty adds the same-turn escalation hint after a denial marker
|
||||
* (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
export function renderPwshResult(
|
||||
result: RenderablePwshResult,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
@@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// Keep the exit marker last because parseExitStatus anchors there.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
||||
// Hint only when the composition exposes escalation, before the final exit marker.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
// A command may trap the termination and exit 0 after timeout; still report interruption.
|
||||
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
||||
if (result.signal !== null) {
|
||||
@@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
* @param sandbox - settled sandbox facts, when this was a confined process.
|
||||
* @param escalationModes - escalation targets advertised by this composition.
|
||||
* @returns the delta text with any loss or sandbox notice appended.
|
||||
*/
|
||||
export function renderPwshProcessRead(read: BashProcessRead): string {
|
||||
export function renderPwshProcessRead(
|
||||
read: BashProcessRead,
|
||||
sandbox?: BashSandboxInfo,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const notices: string[] = []
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
|
||||
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
||||
}
|
||||
if (sandbox?.runnerFailed) {
|
||||
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
||||
} else if (sandbox?.denied) {
|
||||
notices.push(sandboxDenialMarker(sandbox.mode))
|
||||
if (escalationModes.length > 0) {
|
||||
notices.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* text, truncation, timeout, abort, nonzero exits, background handles — so
|
||||
* these tests verify the schema, argument validation, workdir derivation,
|
||||
* managed `DSH_*` collection, abort translation, canonical result projection,
|
||||
* rendering, background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* sandbox denial rendering with the escalation surface, rendering,
|
||||
* background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -22,8 +23,11 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
@@ -150,9 +154,106 @@ async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve
|
||||
* the calling session's standing policy and stamp it on the request, exactly
|
||||
* like the bash tool — the per-session sandbox-policy regression surface.
|
||||
* Records each confined mode and returns scriptable sandbox facts so the
|
||||
* escalation and rendering surfaces are testable without a real backend.
|
||||
*/
|
||||
class ConfiningFakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
modes: Array<string | undefined> = []
|
||||
|
||||
override get sandboxMode() {
|
||||
return 'read-only' as const
|
||||
}
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return runResult('ok\n', {
|
||||
sandbox: {
|
||||
mode: spec.sandboxPolicy?.mode ?? 'read-only',
|
||||
denied: false,
|
||||
...spec.command === 'without optional sandbox facts'
|
||||
? {}
|
||||
: { enforcement: 'full' as const, runnerFailed: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return fakeProcess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */
|
||||
async function setupSandboxed(withApproval = false) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(ConfiningFakeBash)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
const bash = ctx.bash as ConfiningFakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} whose session log carries the sandbox-policy
|
||||
* mode-override event the escalation flow evaluates against, with an
|
||||
* appendable log (the approval service records decisions through
|
||||
* `session.append`).
|
||||
*/
|
||||
function sandboxAgent(
|
||||
mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
|
||||
ctx?: Context,
|
||||
onAppend?: (type: string) => void,
|
||||
): Agent {
|
||||
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
|
||||
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
|
||||
const id = SessionId('sandbox-session')
|
||||
return {
|
||||
id,
|
||||
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
|
||||
session: {
|
||||
id,
|
||||
header: { version: 0, id, createdAt: 0 },
|
||||
events,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
const event = { type, data }
|
||||
events.push(event)
|
||||
onAppend?.(type)
|
||||
return event
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
||||
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
||||
* The fake session carries an empty event log (the sandbox-policy resolver
|
||||
* folds the log for mode overrides, mirroring a real session).
|
||||
*/
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
@@ -160,7 +261,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
session: { id, header: { version: 0, id, createdAt: 0 }, events: [] },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
@@ -397,6 +498,203 @@ describe('execution through the bash seam', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandbox policy resolution', () => {
|
||||
it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
|
||||
const { ctx, bash } = await setupSandboxed()
|
||||
const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
|
||||
const agent = registerFakeAgent(ctx, 'policy-session')
|
||||
Object.assign(agent.session.header, { cwd: sessionCwd })
|
||||
const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
// The policy's workspace root is the session cwd canonicalized by the
|
||||
// policy service (realpath + resolve), NEVER the web server's launch dir;
|
||||
// the calling session's identity rides along for backend per-session state.
|
||||
expect(bash.requests[0]?.sandboxPolicy).toEqual({
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolvePath(realpathSync.native(sessionCwd)),
|
||||
sessionId: 'policy-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => {
|
||||
const { ctx, bash } = await setupSandboxed()
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(bash.requests[0]?.sandboxPolicy).toEqual({
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolvePath(realpathSync.native(process.cwd())),
|
||||
})
|
||||
|
||||
// The base FakeBash advertises no sandboxMode, so the tool must not stamp
|
||||
// any policy (the executor defaulting stays the executor's own).
|
||||
const plain = await setup()
|
||||
await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy')
|
||||
})
|
||||
|
||||
it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ConfiningFakeBash)
|
||||
await expect(ctx.plugin(ToolPwsh)).rejects.toThrow(
|
||||
'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox escalation through ctx.approval', () => {
|
||||
const escalate = {
|
||||
command: 'Write-Output ok',
|
||||
description: 'test escalation',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'the command needs workspace writes',
|
||||
}
|
||||
|
||||
it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
|
||||
const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
|
||||
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.description).toContain('approval prompt')
|
||||
expect(schema.description).toContain('ConstrainedLanguage')
|
||||
expect(schema.description).toContain('named pipes')
|
||||
expect(schema.description).toContain('fails with EPERM')
|
||||
|
||||
for (const args of [
|
||||
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' },
|
||||
{ command: 'Write-Output ok', description: 'd', justification: 'why' },
|
||||
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
|
||||
]) {
|
||||
expect((await call(ctx, 'pwsh', args)).isError).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => {
|
||||
const { ctx } = await setup()
|
||||
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
|
||||
expect(schema.description).not.toContain('ConstrainedLanguage')
|
||||
expect(schema.description).not.toContain('named pipes')
|
||||
expect(schema.description).not.toContain('sandbox_permissions')
|
||||
expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
|
||||
})
|
||||
|
||||
it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
|
||||
const plain = await setup()
|
||||
expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
|
||||
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
const prompted = vi.fn()
|
||||
ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
|
||||
const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
|
||||
expect(text(result)).toContain('not strictly wider')
|
||||
expect(prompted).not.toHaveBeenCalled()
|
||||
|
||||
const malformed = sandboxAgent()
|
||||
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
|
||||
type: 'sandbox/mode',
|
||||
data: { mode: 'unknown-mode' },
|
||||
})
|
||||
expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
|
||||
})
|
||||
|
||||
it('fails closed when approval cannot be routed', async () => {
|
||||
const withoutService = await setupSandboxed()
|
||||
expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')
|
||||
|
||||
const withService = await setupSandboxed(true)
|
||||
expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route')
|
||||
expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rejected', 'user rejected'],
|
||||
['cancelled', 'was cancelled'],
|
||||
] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
|
||||
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
|
||||
expect(text(result)).toContain(message)
|
||||
expect(bash.modes).toEqual([])
|
||||
})
|
||||
|
||||
it('runs a granted foreground or background call under the approved mode', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const agent = sandboxAgent(undefined, ctx)
|
||||
ctx.agents.register(agent)
|
||||
const foreground = await ctx.tools.execute({
|
||||
callId: CallId('sandbox-signal'),
|
||||
name: 'pwsh',
|
||||
arguments: escalate,
|
||||
agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(foreground.isError).toBe(false)
|
||||
const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent)
|
||||
expect(text(background)).toBe('started background task pwsh-1')
|
||||
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
|
||||
})
|
||||
|
||||
it('does not publish detached work when cancellation follows the escalation grant', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const controller = new AbortController()
|
||||
const agent = sandboxAgent(undefined, ctx, (type) => {
|
||||
if (type === 'approval/decided') controller.abort()
|
||||
})
|
||||
ctx.agents.register(agent)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const start = vi.spyOn(bash, 'start')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('cancelled-escalation-background'),
|
||||
name: 'pwsh',
|
||||
arguments: { ...escalate, run_in_background: true },
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
expect(text(result)).toBe('Error: tool call aborted')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const agent = sandboxAgent('workspace-write')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
|
||||
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'without optional sandbox facts',
|
||||
description: 'exercise optional sandbox facts',
|
||||
})
|
||||
if (result.isError) throw new Error('expected foreground pwsh success')
|
||||
expect(result.value).toMatchObject({
|
||||
kind: 'foreground',
|
||||
sandbox: { mode: 'read-only', denied: false },
|
||||
})
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
|
||||
})
|
||||
|
||||
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
|
||||
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
|
||||
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
@@ -641,6 +939,35 @@ describe('UI presentation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshResult sandbox markers', () => {
|
||||
const base = {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out\n', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
|
||||
it('a denied run reports the denial marker before the exit marker', () => {
|
||||
expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } }))
|
||||
.toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]')
|
||||
})
|
||||
|
||||
it('hints only when the composition advertises escalation', () => {
|
||||
const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } }
|
||||
expect(renderPwshResult(denied, ['workspace-write'])).toBe(
|
||||
'out\n[sandbox: file access denied under read-only mode]\n'
|
||||
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
|
||||
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]',
|
||||
)
|
||||
})
|
||||
|
||||
it('a confined run without a denial adds no sandbox marker', () => {
|
||||
expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
@@ -677,6 +1004,20 @@ describe('renderPwshProcessRead', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('appends the runner-failed notice (denial outranked)', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true }))
|
||||
.toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]')
|
||||
})
|
||||
|
||||
it('appends the denial marker and hints only when escalation is advertised', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }))
|
||||
.toBe('x\n[sandbox: file access denied under read-only mode]')
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write']))
|
||||
.toBe('x\n[sandbox: file access denied under read-only mode]\n'
|
||||
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
|
||||
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
6
packages/boot/README.i18n.yaml
Normal file
6
packages/boot/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/README.md
|
||||
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
|
||||
README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56
|
||||
11
packages/boot/README.md
Normal file
11
packages/boot/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/ — shared app-bin boot glue
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).
|
||||
11
packages/boot/README.zh.md
Normal file
11
packages/boot/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/:共享的 app bin 启动粘合层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
|
||||
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。
|
||||
6
packages/boot/app-boot/README.i18n.yaml
Normal file
6
packages/boot/app-boot/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0
|
||||
README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user