From a332f2f3330e9b3a3c6b649f6518567c968ad975 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:08:59 +0800 Subject: [PATCH 001/108] feat(web): configure custom DeepSeek models --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 6 +- .../2026-07-30-web-config-plane.zh.md | 6 +- ...07-24-web-session-model-selector.i18n.yaml | 4 +- .../2026-07-24-web-session-model-selector.md | 6 +- ...026-07-24-web-session-model-selector.zh.md | 6 +- apps/web/tests/message-actions.e2e.ts | 5 +- apps/web/tests/models-settings.e2e.ts | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 44 +- apps/web/tests/seeded-history.e2e.ts | 11 +- .../lifecycle-chrome/plan-active.expected.md | 4 +- .../snapshots/message-actions/ui.expected.md | 4 +- .../models-settings/configured.expected.md | 4 +- .../models.expected.md | 58 +++ .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- packages/client/ui-model/README.i18n.yaml | 4 +- packages/client/ui-model/README.md | 2 +- packages/client/ui-model/README.zh.md | 2 +- .../src/client/ModelSelect.module.css | 7 +- .../ui-model/src/client/ModelSelect.tsx | 14 +- packages/client/ui-model/src/client/index.ts | 4 +- .../client/ui-model/src/client/locales.ts | 6 +- .../ui-model/tests/model-select.spec.tsx | 22 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 7 +- packages/client/ui-models/README.zh.md | 7 +- .../src/client/DeepSeekModelsEditor.tsx | 196 +++++++++ .../src/client/ModelsSection.module.css | 412 ++++++++++++++---- .../ui-models/src/client/ModelsSection.tsx | 4 +- .../ui-models/src/client/ProviderEditor.tsx | 41 +- .../client/ui-models/src/client/locales.ts | 34 ++ .../ui-models/tests/components.spec.tsx | 153 ++++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 39 +- packages/host/apiproxy/src/api/llm.ts | 4 +- .../host/apiproxy/src/api/sessions.schema.ts | 1 - packages/host/apiproxy/src/api/sessions.ts | 2 - .../apiproxy/tests/api-proxy-models.spec.ts | 13 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 - 42 files changed, 948 insertions(+), 211 deletions(-) create mode 100644 apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md create mode 100644 packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..d652bd075a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867 -2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b +2026-07-30-web-config-plane.md: e00c0ed8b5852d416baec73a993102aadded9cd3 +2026-07-30-web-config-plane.zh.md: d5dd5e3c044dc5788367eca55e1cc4110ff81180 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..e00c0ed8b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,9 +18,9 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. +**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Visible profile edits land as `settings.mutate` path operations against the stored redacted section, so a set or unset never rebuilds and drops an unseen secret. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. ## Alternatives considered @@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. A page address for live routes that never declared configurability remains deferred. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..d5dd5e3c04 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,9 +18,9 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。可见的 profile 编辑以 `settings.mutate` 路径操作落到已存储的脱敏分节上,因此 set 或 unset 都不会重建分节并丢掉不可见的机密。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。为从未声明可配置性的存活路由提供页面地址仍然暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b1e6e8b7d1..898a0142fb 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5 -2026-07-24-web-session-model-selector.zh.md: ba76b87b9bd43fff97bf9ea76624f5f75b10e135 +2026-07-24-web-session-model-selector.md: ca13bebbb49aec5deabd147ed1c2a8f3b246ca42 +2026-07-24-web-session-model-selector.zh.md: 16c7d773b0eea82bf991bc17c44b26c8e820aba1 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 003c0b5ac1..ca13bebbb4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -12,11 +12,11 @@ The Web conversation displayed and sent through the Host's fixed provider/model The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, while exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. -`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. +`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current target is absent from the groups, the trigger instead displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model replaces the complete target through the existing selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. @@ -40,4 +40,4 @@ Any Host-backed Web conversation, including a blank session, can switch among dy ## Testing -Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration, unlisted current targets, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, and effort submission. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids. +Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration without stale-row injection, advisory unlisted selection, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, effort submission, and the `Select model` fallback for a removed row. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids; the DeepSeek configuration fixture removes the active catalog row and pins the fallback before choosing a replacement. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index ba76b87b9b..16c7d773b0 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -12,11 +12,11 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;精确解析则决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 -`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 +`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前目标不在分组中时,触发器改为显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过现有选择路径替换完整目标。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 @@ -40,4 +40,4 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm ## 测试 -Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复、当前未列出目标、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示与推理强度提交。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID。 +Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复且不注入陈旧行、建议性的未列出模型选择、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示、推理强度提交,以及已删除模型行的 `Select model` 回退。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID;DeepSeek 配置 fixture 会删除活动目录行,在选择替代模型之前固定该回退。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 4d798e11ed..c44bdfdd69 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -76,9 +76,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) - await page.getByRole('button', { - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 28c423b0a0..e23a5a9e1e 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -55,7 +55,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '+ 添加提供方' }) + const add = dialog.getByRole('button', { name: '添加提供方' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 62dd129982..9ce68c6318 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -16,6 +16,7 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') +const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md') const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => { @@ -85,7 +86,48 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByText('自定义设置').click() + await settings.getByRole('button', { name: '删除模型' }).first().click() + await settings.getByRole('button', { name: '添加模型' }).click() + const customModelId = settings.getByLabel('模型 ID 2') + await customModelId.fill('private-preview') + await settings.getByLabel('显示名称 2').fill('Private Preview') + await settings.getByLabel('上下文窗口 2').fill('131072') + + const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) + await settings.getByRole('button', { name: '保存', exact: true }).click() + await customModelId.waitFor({ state: 'detached', timeout: 15_000 }) + + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('id: deepseek-v4-pro') + expect(document).toContain('id: private-preview') + expect(document).toContain('name: Private Preview') + expect(document).toContain('contextWindow: 131072') + expect(document).not.toContain('id: deepseek-v4-flash') + + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '创建工作区', exact: true }).click() + await page.getByRole('menuitem', { name: '新建工作区', exact: true }).click() + const workspaceDialog = page.getByRole('dialog', { name: '新建工作区' }) + await workspaceDialog.getByLabel('新工作区名称').fill('model-fallback-e2e') + await workspaceDialog.getByRole('button', { name: '创建工作区', exact: true }).click() + await workspaceDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true }) + await modelTrigger.waitFor({ timeout: 10_000 }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0) + await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'models.expected.md']) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3437c41e18..2e4882627a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -152,12 +152,11 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) - await page.getByRole('button', { - // This scenario deliberately leaves the LLM seam open to prove zero - // model calls. History still restores the selected id, but no catalog - // adapter exists to provide its presentation name. - name: 'Select model, current deepseek-v4-flash', - }).waitFor({ timeout: 10_000 }) + // This scenario deliberately leaves the LLM seam open to prove zero + // model calls. History still restores the routed id, but without an + // advertised catalog row the selector prompts for a listed replacement. + await page.getByRole('button', { name: 'Select model', exact: true }) + .waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 8be9ff8b86..eb1002fd5d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -30,8 +30,8 @@ - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode on, press to turn off": Plan -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: Details diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index d423f6f53a..6f7e8cf865 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -40,8 +40,8 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 8b9c4ad6e1..e4697dfa4f 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -17,4 +17,6 @@ - text: minimax-cn 已启用 - button "编辑" - button "删除" - - button "+ 添加提供方" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md new file mode 100644 index 0000000000..31b6c7b1b4 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -0,0 +1,58 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek 已启用 + - button "编辑" + - text: DeepSeek deepseek-official API 密钥 + - textbox "API 密钥": + - /placeholder: 已配置——输入新值可替换 + - group: + - text: 自定义设置 API 地址 + - textbox "API 地址": + - /placeholder: https://api.deepseek.com + - text: 推理强度 + - combobox "推理强度": + - option "默认" [selected] + - option "off" + - option "high" + - option "max" + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - textbox "模型 ID 1": deepseek-v4-pro + - textbox "显示名称 1": + - /placeholder: 留空时使用模型 ID + - text: DeepSeek-V4-Pro + - spinbutton "上下文窗口 1": "1000000" + - button "删除模型": + - img + - text: 删除模型 + - textbox "模型 ID 2": private-preview + - textbox "显示名称 2": + - /placeholder: 留空时使用模型 ID + - text: Private Preview + - spinbutton "上下文窗口 2": "131072" + - button "删除模型": + - img + - text: 删除模型 + - button "添加模型": + - img + - text: 添加模型 + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index f722bb36ae..2d8fb1fce9 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -45,8 +45,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 61d3996414..a2d14e611a 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -43,8 +43,8 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 9620b15513..5d40ab44b9 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/README.i18n.yaml @@ -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/client/ui-model/README.md -README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642 -README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4 +README.md: e456371095166569ed9e36fab19627ece9a751b0 +README.zh.md: c72eb33163aa5ef855489cbbd46cac9edb93afb2 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 267717c784..e456371095 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 6d6f433315..c72eb33163 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。 +模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index f9d6cc10e6..fce9b2c28f 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -197,8 +197,7 @@ white-space: nowrap; } -.description, -.unlisted { +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -207,10 +206,6 @@ white-space: nowrap; } -.unlisted { - color: var(--dsw-alias-state-warn-label); -} - .check { display: grid; place-items: center; diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 4cc2687832..8396e42b5a 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -169,8 +169,13 @@ export function ModelSelect( }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') + const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` + const triggerAria = currentChoice === undefined + ? t('trigger.selectAria') + : effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }) itemRefs.current = [] let itemIndex = 0 const itemRef = () => { @@ -184,9 +189,7 @@ export function ModelSelect( ref={triggerRef} type="button" className={css.trigger} - aria-label={effortLabel === undefined - ? t('trigger.aria', { model: modelLabel }) - : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} + aria-label={triggerAria} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -272,9 +275,6 @@ export function ModelSelect( {model.description !== undefined && ( {model.description} )} - {model.unlisted === true && ( - {t('option.currentUnlisted')} - )} {selected ? : null} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index a672c6935a..deebf3b000 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -49,9 +49,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: model.unlisted === true - ? t('option.unlisted', { group: group.name }) - : model.description !== undefined ? `${group.name} · ${model.description}` : group.name, + detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name, ...(directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}), }) diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index 856c95e470..8b83870c40 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -3,9 +3,9 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'command.description': '选择本会话使用的模型', - 'option.unlisted': '{group} · 未列入目录', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', + 'trigger.selectAria': '选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', @@ -16,7 +16,6 @@ export const zh = { 'error.action': '模型操作失败:{message}', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', - 'option.currentUnlisted': '当前模型 · 未列入目录', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -27,9 +26,9 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'command.description': 'Select the model for this conversation', - 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', 'trigger.fallback': 'Select model', + 'trigger.selectAria': 'Select model', 'trigger.aria': 'Select model, current {model}', 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', 'menu.aria': 'Model and reasoning effort', @@ -40,7 +39,6 @@ export const en = { 'error.action': 'Model operation failed: {message}', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', - 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index d1dc4f7d1b..3fc459b9c1 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -108,4 +108,26 @@ describe('ModelSelect reasoning effort', () => { expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) .toEqual(['Default', 'Standard']) }) + + it('prompts for a new selection when the current target is no longer advertised', () => { + const directory = createSnapshotStore(state({ + current: { provider: 'deepseek-official', model: 'removed-model' }, + })) + const select = vi.fn().mockResolvedValue(true) + render() + + const trigger = screen.getByRole('button', { name: '选择模型' }) + expect(trigger.textContent).toContain('选择模型') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull() + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByText('removed-model')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + }) }) diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 951b1d04fe..2cf8870b64 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -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/client/ui-models/README.md -README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79 -README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef +README.md: bbd1ad70afd925b2c0b28f444cf8118e241a7723 +README.zh.md: 99ae1e432e261def378a5fac242d24d36a870905 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index adfbc084e1..bbd1ad70af 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries `baseURL` for both families, `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits. A provider row is deletable only when the user layer alone carries it (removal restores the composition base). The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. Empty ids, duplicate ids, empty explicit names, and non-positive or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. -- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4ee7d4efa7..99ae1e432e 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载两个家族的 `baseURL`、deepseek 的 `reasoningEffort` 或 pi-ai 的 `reasoning`,以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留。只有当某个提供方行仅由用户层承载时它才可删除(删除会还原组合 base)。 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。空 ID、重复 ID、显式填写的空名称,以及非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -20,7 +20,6 @@ ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 -- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx new file mode 100644 index 0000000000..c79eb57412 --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -0,0 +1,196 @@ +/** + * Curated editor for the direct DeepSeek adapter's advisory model catalog. + * The settings layer replaces `models` as one array, so the parent supplies + * the effective inherited rows until the first edit materializes a user + * override; reset removes that override instead of copying defaults into it. + */ + +import type { ReactNode } from 'react' +import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** One catalog entry kept structurally open so hidden or future fields survive an edit. */ +export type DeepSeekModelDraft = Record + +/** A localized validation failure for one user-owned model array. */ +export interface DeepSeekModelsValidationFailure { + /** Zero-based model position. */ + index: number + /** Message key owned by the Models settings section. */ + key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid' +} + +/** Convert a schema-validated catalog value into records without dropping hidden fields. */ +export function modelDrafts(value: unknown): DeepSeekModelDraft[] { + if (!Array.isArray(value)) return [] + return value.map(entry => + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ? entry as DeepSeekModelDraft + : {}) +} + +/** + * Validate adapter constraints that the serialized schema cannot express. + * @param value - user-owned `models` value, or undefined while inherited. + * @returns the first invalid row, or undefined when the adapter will accept it. + */ +export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined { + if (value === undefined) return undefined + const models = modelDrafts(value) + const seen = new Set() + for (const [index, model] of models.entries()) { + const id = model['id'] + if (typeof id !== 'string' || id.length === 0) return { index, key: 'modelIdRequired' } + if (seen.has(id)) return { index, key: 'modelIdDuplicate' } + seen.add(id) + const name = model['name'] + if (name !== undefined && (typeof name !== 'string' || name.length === 0)) { + return { index, key: 'modelNameInvalid' } + } + const contextWindow = model['contextWindow'] + if (contextWindow !== undefined + && (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) { + return { index, key: 'modelContextInvalid' } + } + } + return undefined +} + +/** Props of {@link DeepSeekModelsEditor}. */ +export interface DeepSeekModelsEditorProps { + /** Effective rows: inherited until the parent materializes an override. */ + models: readonly DeepSeekModelDraft[] + /** Whether the user layer currently owns the whole array. */ + overridden: boolean + /** Fallback capacity used when a row omits its exact value. */ + defaultContextWindow: number | undefined + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable every mutation. */ + disabled: boolean + /** Replace the user-owned array after one visible edit. */ + onChange: (models: DeepSeekModelDraft[]) => void + /** Remove the user-owned array and return to inheritance. */ + onReset: () => void +} + +/** + * Render the direct DeepSeek adapter's id/name/context-window catalog. + * @param props - effective rows plus the array-level override actions. + * @returns the catalog editor. + */ +export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { + const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { + const next = props.models.map((model, at) => { + const copy = { ...model } + if (at !== index) return copy + if (value === undefined) Reflect.deleteProperty(copy, key) + else copy[key] = value + return copy + }) + props.onChange(next) + } + + const remove = (index: number): void => { + props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) + } + + return ( +
+
+
+ {props.t('models')} + + {props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')} + +
+ {props.overridden + ? ( + + ) + : null} +
+ {props.models.length === 0 + ?

{props.t('modelsEmpty')}

+ : ( +
+ {/* Captions sit above the rows and are hidden from assistive tech: + every field already carries the indexed `aria-label` naming it. */} + + {props.models.map((model, index) => ( +
+ { update(index, 'id', event.target.value) }} + /> + { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + { + update( + index, + 'contextWindow', + event.target.value === '' ? undefined : Number(event.target.value), + ) + }} + /> + +
+ ))} +
+ )} + +
+ ) +} diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a2be484a63..798f45126c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -1,3 +1,13 @@ +/* Models settings section, in the settings-panel design language: 14/22 body, + * 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense), + * 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and + * the Button/Input primitives already use. + * + * Every color resolves through a `--dsw-alias-*` token. The section used to + * name `--border` / `--surface` / `--text-*`, which nothing in this app + * defines, so it always rendered the light-mode literals written as their + * fallbacks and stayed light under the dark theme. */ + .section { display: flex; flex-direction: column; @@ -7,20 +17,24 @@ .title { margin: 0; - font-size: 18px; - font-weight: 600; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .intro { margin: 0; - font-size: 13px; - color: var(--text-tertiary, #888); + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; - color: var(--text-warning, #a15c00); + line-height: 18px; + color: var(--dsw-alias-state-warn-label); } .rows { @@ -29,17 +43,18 @@ padding: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } +/* A configured provider: outlined on the panel fill, so the filled editor + card it expands into reads as the nested object. */ .rowCard { - border: 1px solid var(--border, #e2e2e2); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; - background: var(--surface, #fff); } .rowHead { @@ -49,8 +64,10 @@ } .rowName { - font-size: 15px; - font-weight: 600; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .badges { @@ -63,8 +80,9 @@ display: inline-flex; align-items: center; gap: 5px; - color: var(--text-success, #0a7d33); + color: var(--dsw-alias-state-success-primary); font-size: 12px; + line-height: 18px; } .badgeOk::before { @@ -76,59 +94,118 @@ } .badgeMuted { - color: var(--text-tertiary, #999); - font-size: 12px; -} - -.badgeWarn { - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-label-tertiary); font-size: 12px; + line-height: 18px; } .rowActions { display: inline-flex; - gap: 8px; + align-items: center; + gap: 4px; +} + +/* `box-sizing` on every control here: the app has no global border-box reset, + so without it the outlined variants stand 2px taller than the filled ones + they sit beside (Cancel next to Apply, Edit next to Delete). */ +.primaryButton, +.secondaryButton, +.addButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + font: inherit; + font-size: 14px; + line-height: 22px; + cursor: pointer; } .primaryButton { - border: none; - border-radius: 999px; - padding: 8px 18px; - background: var(--accent-strong, #111); - color: var(--text-inverse, #fff); - font: inherit; - cursor: pointer; + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); } -.secondaryButton { - border: 1px solid var(--border, #d9d9d9); - border-radius: 999px; - padding: 6px 14px; - background: var(--surface, #fff); - color: inherit; - font: inherit; - cursor: pointer; +.primaryButton:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); +} + +.secondaryButton, +.addButton { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; + color: var(--dsw-alias-label-primary); +} + +.secondaryButton:hover:not(:disabled), +.addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); } .dangerButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; border: none; - background: none; - color: var(--text-danger, #c0392b); + border-radius: 18px; + background: transparent; + color: var(--dsw-alias-state-error-primary); font: inherit; + font-size: 14px; + line-height: 22px; cursor: pointer; } +.dangerButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Provider-row controls take the dense capsule (Button `.sm`). */ +.rowActions .secondaryButton, +.rowActions .dangerButton { + height: 28px; + padding: 0 10px; + border-radius: 14px; + font-size: 12px; + line-height: 18px; +} + .primaryButton:disabled, .secondaryButton:disabled, -.dangerButton:disabled { - opacity: 0.5; +.dangerButton:disabled, +.addButton:disabled, +.linkButton:disabled, +.addModelButton:disabled, +.rowDelete:disabled { + opacity: 0.4; cursor: default; } +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.dangerButton:focus-visible, +.addButton:focus-visible, +.linkButton:focus-visible, +.addModelButton:focus-visible, +.rowDelete:focus-visible, +.customizedSummary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +/* Editing surface: a filled module on the panel, matching the settings + selector fill rather than adding another outline inside the row. */ .editor { - border: 1px solid var(--border, #e6e6e6); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -143,12 +220,15 @@ .editorTitle { font-size: 14px; - font-weight: 600; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .editorRoute { font-size: 12px; - color: var(--text-tertiary, #999); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } .field { @@ -162,30 +242,37 @@ align-items: center; gap: 10px; font-size: 12px; + line-height: 18px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); } .linkButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; border: none; - background: none; - padding: 0; - color: var(--text-tertiary, #888); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; - text-decoration: underline; + line-height: 18px; cursor: pointer; } -.linkButton:disabled { - opacity: 0.5; - cursor: default; +.linkButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); } .advancedHint { margin: 0; font-size: 12px; - color: var(--text-tertiary, #999); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } .editorActions { @@ -202,26 +289,12 @@ .addButton { align-self: flex-start; - border: 1px solid var(--border, #d9d9d9); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - font-size: 13px; - background: var(--surface, #fff); - color: inherit; - cursor: pointer; -} - -.addButton:disabled { - opacity: 0.5; - cursor: default; } .addCard, .setupCard { - border: 1px solid var(--border, #e6e6e6); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -229,24 +302,56 @@ list-style: none; } +/* Nested in a card that already carries the module chrome. */ .addCard .editor, .setupCard .editor { - border: none; background: none; padding: 0; } .customized { - border-top: 1px solid var(--border, #ececec); + border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 10px; } +/* Native disclosure marker replaced by a rotating chevron: the built-in + triangle differs per engine and cannot take the label color. */ .customizedSummary { + display: flex; + align-items: center; + gap: 6px; + width: fit-content; + padding: 2px 4px; + margin-left: -4px; + border-radius: 6px; cursor: pointer; font-size: 12px; + line-height: 18px; font-weight: 500; - color: var(--text-secondary, #555); - list-style: revert; + color: var(--dsw-alias-label-secondary); + list-style: none; +} + +.customizedSummary::-webkit-details-marker { + display: none; +} + +.customizedSummary::before { + content: ''; + width: 5px; + height: 5px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + transform: rotate(-45deg) translate(-1px, -1px); + transition: transform 120ms ease; +} + +.customized[open] > .customizedSummary::before { + transform: rotate(45deg) translate(-1px, -1px); +} + +.customizedSummary:hover { + color: var(--dsw-alias-label-primary); } .customizedBody { @@ -256,28 +361,179 @@ padding-top: 12px; } +/* Model catalog: a table, not a stack of cards. The column captions are + written once above the rows, so a row is one line of fields plus its + delete control; each field still carries the indexed `aria-label` that + names it, and the caption strip is hidden from assistive tech to keep + that name from being announced twice. */ +.modelCatalog { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +.modelCatalogHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.modelCatalogHeading { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modelCatalogTitle { + font-size: 12px; + line-height: 18px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.modelCatalogMeta, +.modelEmpty { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.modelTable { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Captions and rows share one track list so the columns line up. */ +.modelColumns, +.modelRow { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px; + align-items: center; + gap: 8px; +} + +.modelColumns { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +/* The inset belongs on the caption cell, not the strip: padding on the grid + container would narrow its tracks against the rows' and walk the captions + left column by column. 1px border + 10px padding is the field text inset. */ +.modelColumns > span { + padding-left: 11px; +} + +.rowDelete { + box-sizing: border-box; + position: relative; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.rowDelete:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + +.modelEmpty { + padding: 12px; + border: 1px dashed var(--dsw-alias-border-l3); + border-radius: 8px; + text-align: center; +} + +.addModelButton { + box-sizing: border-box; + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.addModelButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .input { box-sizing: border-box; - padding: 9px 12px; - border: 1px solid var(--border, #d9d9d9); - border-radius: 10px; + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; font: inherit; - font-size: 13px; - background: var(--surface, #fff); - color: inherit; + font-size: 14px; + line-height: 22px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +/* Enum pickers hold a handful of short options; a field-width dropdown reads + as a text field the user is expected to fill. */ +select.input { + max-width: 240px; + cursor: pointer; } .input:focus { outline: none; - border-color: var(--accent-strong, #111); + border-color: var(--dsw-alias-brand-primary); } .input::placeholder { - color: var(--text-tertiary, #aaa); + color: var(--dsw-alias-label-dimmed); +} + +.input:disabled { + opacity: 0.6; + cursor: default; } .error { margin: 0; font-size: 12px; - color: var(--text-danger, #c0392b); + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +/* Icon-button label seat: named for assistive tech and for the tests that + query these controls by their text. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .customizedSummary::before { + transition: none; + } } diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index d095acb86c..4dea825a2d 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -11,6 +11,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -272,7 +273,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { setEditing(targetOf(first)) }} > - {`+ ${t('add')}`} + + {t('add')} )} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 07b5dfae54..746d1cba35 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -5,19 +5,23 @@ * under the profile's reference, deriving `_API_KEY` when the profile * has none, and the pi-ai profile records that derivation as `apiKeyEnv`); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, plus `reasoningEffort` for deepseek / `reasoning` for - * pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as - * minimal `settings.mutate` path ops against the stored section — the card - * reads the redacted descriptor, so it names only the fields it can see and a - * stored literal secret is never collaterally removed. + * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and + * DeepSeek's id/name/context-window model catalog). Everything else stays + * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` + * path ops against the stored section — the card reads the redacted + * descriptor, so it names only the fields it can see and a stored literal + * secret is never collaterally removed. */ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client' import { - deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, + deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '@deepseek-ai/dsh-client-schema-form' +import { + DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, +} from './DeepSeekModelsEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { && stringAt(fallback, 'apiKeyEnv') === undefined ? setPath(draft, ['apiKeyEnv'], keyRef) : draft + if (layout === 'deepseek') { + const modelFailure = validateDeepSeekModels(getPath(next, ['models'])) + if (modelFailure !== undefined) { + return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}` + } + } /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */ if (node !== undefined && settingsPath.length === 0) { const sectionError = validateDraft(node, next) @@ -236,6 +246,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { */ const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { const effortField = EFFORT_FIELD[family] + const customModels = getPath(draft, ['models']) + const modelsOverridden = hasPath(draft, ['models']) + const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models'])) + const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) return ( <>
@@ -289,6 +303,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ))}
+ {family === 'deepseek' + ? ( + { setDraft(current => setPath(current, ['models'], next)) }} + onReset={() => { setDraft(current => deletePath(current, ['models'])) }} + /> + ) + : null} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..c41846216b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -27,6 +27,23 @@ export const en = { baseUrlDefault: 'Provider default', effort: 'Reasoning effort', effortInherit: 'Default', + models: 'Models', + modelsInherited: 'Using the adapter defaults', + modelsCustomized: 'Customized model catalog', + resetModels: 'Restore defaults', + model: 'Model', + modelId: 'Model ID', + modelName: 'Display name', + modelNamePlaceholder: 'Uses the model ID when empty', + contextWindow: 'Context window', + contextWindowPlaceholder: 'Uses the provider default', + addModel: 'Add model', + removeModel: 'Delete model', + modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + modelIdRequired: 'Model ID is required.', + modelIdDuplicate: 'Model ID must be unique.', + modelNameInvalid: 'Display name cannot be empty.', + modelContextInvalid: 'Context window must be a positive integer.', advancedHint: 'Other fields live in settings.yaml; edit that section directly.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -64,6 +81,23 @@ export const zh: typeof en = { baseUrlDefault: '提供方默认', effort: '推理强度', effortInherit: '默认', + models: '模型目录', + modelsInherited: '正在使用适配器默认模型', + modelsCustomized: '已自定义模型目录', + resetModels: '恢复默认模型', + model: '模型', + modelId: '模型 ID', + modelName: '显示名称', + modelNamePlaceholder: '留空时使用模型 ID', + contextWindow: '上下文窗口', + contextWindowPlaceholder: '使用提供方默认值', + addModel: '添加模型', + removeModel: '删除模型', + modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + modelIdRequired: '模型 ID 不能为空。', + modelIdDuplicate: '模型 ID 不能重复。', + modelNameInvalid: '显示名称不能为空。', + modelContextInvalid: '上下文窗口必须是正整数。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..bbfe4782da 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' +import { + DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, +} from '../src/client/DeepSeekModelsEditor.tsx' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -32,15 +35,38 @@ const DeepSeekConfig = Schema.object({ apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), + defaultContextWindow: Schema.number().step(1).min(1), + models: Schema.array(Schema.object({ + id: Schema.string().required(), + name: Schema.string(), + description: Schema.string(), + contextWindow: Schema.number().step(1).min(1), + })), }) +const DEFAULT_DEEPSEEK_MODELS = [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Preserved hidden detail', + contextWindow: 1_000_000, + }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, +] + function wireNamespaces(): SettingsNamespaceView[] { return [ { ns: 'llm-deepseek', schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, - value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' }, - base: {}, + value: { + apiKeyEnv: 'DEEPSEEK_API_KEY', + baseURL: 'https://base', + reasoningEffort: 'high', + defaultContextWindow: 1_000_000, + models: DEFAULT_DEEPSEEK_MODELS, + }, + base: { defaultContextWindow: 1_000_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], @@ -156,7 +182,7 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.getAllByText(en.active)).toHaveLength(1) expect(screen.getByText(en.dormant)).toBeTruthy() - expect(screen.getByText(`+ ${en.add}`)).toBeTruthy() + expect(screen.getByText(en.add)).toBeTruthy() }) it('turns the setup card into a row once the credential reports configured', async () => { @@ -245,6 +271,115 @@ describe('ModelsSection', () => { }) }) + it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) + + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) + fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) + fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + ...DEFAULT_DEEPSEEK_MODELS, + { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 }, + ], + }], + expectedRevision: 0, + }) + }) + + it('rejects duplicate DeepSeek model ids before writing', async () => { + const { mutate } = await mountSection() + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getByText(en.addModel)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } }) + fireEvent.click(screen.getByText(en.apply)) + + await screen.findByText(`Model 3: ${en.modelIdDuplicate}`) + expect(mutate).not.toHaveBeenCalled() + }) + + it('validates every adapter-owned model catalog invariant', () => { + expect(modelDrafts(undefined)).toEqual([]) + expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }]) + expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + expect(validateDeepSeekModels([{ id: 'model', name: '' }])) + .toEqual({ index: 0, key: 'modelNameInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }])) + .toEqual({ index: 0, key: 'modelContextInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined() + }) + + it('renders malformed draft fallbacks without inventing catalog values', () => { + render() + expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('') + expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder) + .toBe(en.contextWindowPlaceholder) + }) + + it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement) + fireEvent.click(screen.getByText(en.removeModel)) + expect(screen.getByText(en.modelsEmpty)).toBeTruthy() + fireEvent.click(screen.getByText(en.resetModels)) + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + + const names = screen.getAllByLabelText(new RegExp(en.modelName)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [ + { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' }, + DEFAULT_DEEPSEEK_MODELS[1], + ], + }], + expectedRevision: 0, + }) + }) + it('clears an inherited override with an unset op, never a whole-section replace', async () => { // The data-loss shape: the old path rebuilt the section from the REDACTED // user layer and replaced it wholesale, deleting any stored literal key. @@ -333,7 +468,7 @@ describe('ModelsSection', () => { it('adds a dormant provider with a derived reference and stores its key', async () => { const { mutate, set } = await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') @@ -357,7 +492,7 @@ describe('ModelsSection', () => { it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) fireEvent.change(pick, { target: { value: 'broken' } }) await screen.findByText(/unresolvable settings path/) @@ -375,7 +510,7 @@ describe('ModelsSection', () => { const { set } = await mountSection({ mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))), }) - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText(en.keyInput) fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) @@ -515,7 +650,7 @@ describe('ModelsSection', () => { />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText(en.remove).every(button => button.disabled)).toBe(true) - expect(screen.getByText(`+ ${en.add}`).disabled).toBe(true) + expect(screen.getByText(en.add).disabled).toBe(true) }) it('toggles the row editor closed on a second edit click and on cancel', async () => { @@ -534,10 +669,10 @@ describe('ModelsSection', () => { it('cancels the add card back to the add button', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - await screen.findByText(`+ ${en.add}`) + await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..a962d9dfeb 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: e57ea657c5432612ef024fc585febc0896a3ab43 +README.zh.md: 57d2604dedbd86102ab5ef9fbe156792aeb5905f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..e57ea657c5 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -18,7 +18,7 @@ Session titles ride the generic projection pair like every other domain — the `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..57d2604ded 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,7 +18,7 @@ `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..018f1ea053 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -126,30 +126,19 @@ function ok(request: RpcRequest, value: T): RpcResponse { /** * Build the provider/model catalog over every registered route. Shared by the - * session-scoped `session.models` (which passes the session's current target - * so an unlisted current model still renders selectable) and the host-scoped - * `llm.models` (no current). Per-provider failures ride `failures` without - * failing the sound groups; groups that advertise nothing are dropped. + * session-scoped `session.models` and host-scoped `llm.models`. Catalog + * membership stays advisory: an unlisted session target remains valid for + * provider dispatch, but is not injected back into the selector after its + * owning catalog stops advertising it. Per-provider failures ride `failures` + * without failing the sound groups; groups that advertise nothing are dropped. */ -async function buildModelCatalog( - ctx: Context, - current?: { provider: string; model: string }, -): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> { +async function buildModelCatalog(ctx: Context): Promise<{ + groups: ModelProviderGroup[] + failures: ModelCatalogFailure[] +}> { const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { try { - const advertised = await ctx.llm.listModels(provider.id) - const models = [...advertised] - if ( - current !== undefined - && provider.id === current.provider - && !models.some(model => model.id === current.model) - ) { - models.push({ - provider: provider.id, - id: current.model, - name: current.model, - }) - } + const models = await ctx.llm.listModels(provider.id) const entries = await Promise.all(models.map(async (model) => { const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined @@ -170,12 +159,6 @@ async function buildModelCatalog( id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, - ...current !== undefined - && provider.id === current.provider - && model.id === current.model - && !advertised.some(candidate => candidate.id === current.model) - ? { unlisted: true as const } - : {}, ...reasoning === undefined ? {} : { reasoning }, } })) @@ -1394,7 +1377,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current - const { groups, failures } = await buildModelCatalog(ctx, current) + const { groups, failures } = await buildModelCatalog(ctx) return ok(request, { current: { ...current }, groups, failures }) }, diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 59a21cf12a..a62319fd62 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -3,8 +3,8 @@ * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the * live route registry; `llm.models` is the session-independent model catalog - * (`session.models` minus the per-session current/unlisted logic). Both - * invalidate on the `host/models-changed` frame. + * (the same groups as `session.models`, without the per-session current + * target). Both invalidate on the `host/models-changed` frame. */ import type { RpcRequest, RpcResponse } from './rpc.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index bb044d4428..5189a6a9ec 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -164,7 +164,6 @@ export const modelCatalogModelSchema = z.object({ id: z.string().min(1), name: z.string().min(1), description: z.string().optional(), - unlisted: z.literal(true).optional(), reasoning: modelReasoningSchema.optional(), }) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index e6f93c0dae..0372fe3283 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -89,8 +89,6 @@ export interface ModelCatalogModel { name: string /** Optional provider-supplied description. */ description?: string - /** The current model was inserted because the advisory catalog omitted it. */ - unlisted?: true /** Exact-route reasoning metadata when the adapter exposes it. */ reasoning?: ModelReasoning } diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 5197d96436..2a4754f144 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -1,7 +1,8 @@ /** * Web session model-directory and selection behavior: dynamic provider grouping, - * provider-local catalog failures, logged-target restoration, advisory unlisted - * models, and the prompt-assembly boundary for a running selection change. + * provider-local catalog failures, logged-target restoration without stale + * catalog injection, advisory pass-through models, and the prompt-assembly + * boundary for a running selection change. */ import { describe, expect, it } from 'vitest' @@ -118,7 +119,7 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { + it('groups successful providers and leaves an unlisted current target out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'private-preview', @@ -143,12 +144,6 @@ describe('Web session model selection', () => { description: 'Reasoning model', reasoning: REASONING, }, - { - id: 'private-preview', - name: 'private-preview', - unlisted: true, - reasoning: REASONING, - }, ], }]) expect(catalog.failures).toEqual([ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..fa6b5abe8c 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -203,7 +203,6 @@ describe('sessions domain schemas', () => { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'fast', - unlisted: true, reasoning: { efforts: [ { id: 'off', name: 'Off' }, From 935578ed9878d5cc8388204c7344cabe181bfaa6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:36:16 +0800 Subject: [PATCH 002/108] feat(web): accept K and M suffixes in the context window field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog's context window is now a text field that reads a decimal K or M suffix — 1M is 1000K, matching how model capacities are quoted — and stores the plain token count, so settings.yaml and the adapter are unchanged. A stored count reads back in the shortest form that round-trips: 1000000 as 1M, 256000 as 256K, and 131072 written out, because it is not a whole number of thousands. The field holds the typed text while its row has focus, since re-deriving it from the parsed count on every keystroke would rewrite 1000 to 1K mid-word; text that does not parse stays on screen so the save-time rejection names a row the user can still see and correct. --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- .../models.expected.md | 10 ++- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/DeepSeekModelsEditor.tsx | 81 ++++++++++++++--- .../client/ui-models/src/client/locales.ts | 4 +- .../ui-models/tests/components.spec.tsx | 90 ++++++++++++++++++- 10 files changed, 177 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 2421df88f6..32137fe409 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 0e457b6d712cf9e7e2b005f61b97c580a0f2597e -2026-07-30-web-config-plane.zh.md: 4b2a0bd87040e60bc6602c48482bc936e90011ec +2026-07-30-web-config-plane.md: a4d474d450009b3bcf929eaea870e045602268ac +2026-07-30-web-config-plane.zh.md: db7201408f46a1e68b4359346d1c74b8d728a0d3 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 0e457b6d71..a4d474d450 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,7 +18,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. +**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. The context window is a text field reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: the field holds the typed text while the row has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. **The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 4b2a0bd870..db7201408f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,7 +18,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。上下文窗口是一个文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:该行持有焦点期间,字段保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。 **Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 31b6c7b1b4..4a709fdf5a 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -14,7 +14,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: DeepSeek 已启用 + - text: DeepSeek - button "编辑" - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": @@ -36,7 +36,9 @@ - textbox "显示名称 1": - /placeholder: 留空时使用模型 ID - text: DeepSeek-V4-Pro - - spinbutton "上下文窗口 1": "1000000" + - textbox "上下文窗口 1": + - /placeholder: 1M + - text: 1M - button "删除模型": - img - text: 删除模型 @@ -44,7 +46,9 @@ - textbox "显示名称 2": - /placeholder: 留空时使用模型 ID - text: Private Preview - - spinbutton "上下文窗口 2": "131072" + - textbox "上下文窗口 2": + - /placeholder: 1M + - text: "131072" - button "删除模型": - img - text: 删除模型 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 8f7f098dfc..7b4f1ef045 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -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/client/ui-models/README.md -README.md: 97dd6cca4ba1715dc75211929c47fc6f3e632d78 -README.zh.md: d142e9a94c86a4df3f49ad2ee210e24c267f92e0 +README.md: a1d5233eb47043f86d3ae562419ac9ec22404d60 +README.zh.md: 0a5f3fd97f1d54c91d1305ec7b759c5f2934d961 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 97dd6cca4b..a1d5233eb4 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. Empty ids, duplicate ids, empty explicit names, and non-positive or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A context window is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index d142e9a94c..0a5f3fd97f 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。空 ID、重复 ID、显式填写的空名称,以及非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。上下文窗口按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx index c79eb57412..d80ab9f741 100644 --- a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -5,6 +5,7 @@ * override; reset removes that override instead of copying defaults into it. */ +import { useState } from 'react' import type { ReactNode } from 'react' import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' @@ -13,6 +14,47 @@ import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ export type DeepSeekModelDraft = Record +/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */ +const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i + +/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */ +const CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const + +/** + * Read a typed context window, so a user can write `256K` or `1M` instead of + * counting zeroes. The stored value stays a plain token count. + * @param text - raw field text. + * @returns the count; `undefined` when blank (inherit), `NaN` when unreadable + * (rejected by {@link validateDeepSeekModels} before any write). + */ +export function parseContextWindow(text: string): number | undefined { + const trimmed = text.trim() + if (trimmed.length === 0) return undefined + const match = CONTEXT_WINDOW_PATTERN.exec(trimmed) + if (match === null) return Number.NaN + const suffix = match[2]?.toLowerCase() + const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1 + const scaled = Number(match[1]) * scale + // A decimal multiple is exact in intent but not in binary floating point + // (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back. + const rounded = Math.round(scaled) + return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled +} + +/** + * Spell a stored count back in the shortest form that survives a round trip + * through {@link parseContextWindow}; a count that is not a whole number of + * thousands stays written out. + * @param value - stored context window. + * @returns the field text. + */ +export function formatContextWindow(value: number): string { + if (!Number.isInteger(value) || value <= 0) return String(value) + if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M` + if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K` + return String(value) +} + /** A localized validation failure for one user-owned model array. */ export interface DeepSeekModelsValidationFailure { /** Zero-based model position. */ @@ -81,6 +123,11 @@ export interface DeepSeekModelsEditorProps { * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { + // The context-window field is edited as text, so the keystrokes are held + // here while one row has focus: re-deriving the text from the parsed count + // on every change would rewrite `1000` to `1K` mid-word. + const [editing, setEditing] = useState<{ index: number; text: string } | undefined>(undefined) + const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { const next = props.models.map((model, at) => { const copy = { ...model } @@ -93,9 +140,27 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod } const remove = (index: number): void => { + setEditing(undefined) props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) } + /** The row's field text: the live keystrokes, else the stored count spelled short. */ + const contextText = (model: DeepSeekModelDraft, index: number): string => { + if (editing?.index === index) return editing.text + const value = model['contextWindow'] + return typeof value === 'number' ? formatContextWindow(value) : '' + } + + const settleContext = (index: number): void => { + setEditing((current) => { + if (current?.index !== index) return current + // Unreadable text stays on screen: the save-time rejection names a row + // the user can still see and correct. + const parsed = parseContextWindow(current.text) + return parsed !== undefined && Number.isNaN(parsed) ? current : undefined + }) + } + return (
@@ -152,22 +217,18 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod /> { - update( - index, - 'contextWindow', - event.target.value === '' ? undefined : Number(event.target.value), - ) + setEditing({ index, text: event.target.value }) + update(index, 'contextWindow', parseContextWindow(event.target.value)) }} + onBlur={() => { settleContext(index) }} /> @@ -203,6 +232,12 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod aria-label={`${props.t('modelId')} ${String(index + 1)}`} disabled={props.disabled} onChange={(event) => { update(index, 'id', event.target.value) }} + onBlur={(event) => { + // Settle a pasted id rather than trimming per keystroke, + // which would stop the user typing an interior space. + const trimmed = event.target.value.trim() + if (trimmed !== event.target.value) update(index, 'id', trimmed) + }} /> { - setEditing({ index, text: event.target.value }) - update(index, 'contextWindow', parseContextWindow(event.target.value)) + const text = event.target.value + setEditing(current => new Map(current).set(index, text)) + update(index, 'contextWindow', parseContextWindow(text)) }} onBlur={() => { settleContext(index) }} /> diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9ebadd1f3..7346332a2f 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -477,6 +477,91 @@ describe('ModelsSection', () => { .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment']) }) + it('keeps every row\'s unreadable text, not just the last one edited', async () => { + // The regression: one active buffer meant editing a second row displaced + // the first, which then fell back to rendering its stored NaN as `NaN` — + // losing the text the user was told they could still correct. + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } }) + + expect((windows[0] as HTMLInputElement).value).toBe('not a number') + expect((windows[1] as HTMLInputElement).value).toBe('2M') + }) + + it('re-keys the typed text around a removed row', async () => { + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const windows = (): HTMLInputElement[] => + screen.getAllByLabelText(new RegExp(en.contextWindow)) + const removeRow = (at: number): void => { + fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement) + } + // Three rows, with text parked on the outer two. + fireEvent.click(screen.getByText(en.addModel)) + fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } }) + fireEvent.blur(windows()[0] as HTMLInputElement) + fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } }) + fireEvent.blur(windows()[2] as HTMLInputElement) + + // Dropping the middle row leaves the row above untouched and carries the + // row below down with its own text, rather than stranding it. + removeRow(1) + expect(windows()).toHaveLength(2) + expect((windows()[0] as HTMLInputElement).value).toBe('top text') + expect((windows()[1] as HTMLInputElement).value).toBe('bottom text') + + // Dropping a row that holds text takes that text with it; the survivor + // keeps its own rather than inheriting the deleted row's. + removeRow(0) + expect(windows()).toHaveLength(1) + expect((windows()[0] as HTMLInputElement).value).toBe('bottom text') + }) + + it('drops the typed text when reset replaces the rows it annotated', async () => { + // The regression: reset removed the override but left the buffer, so an + // inherited row displayed text no settings layer stores — and because an + // unreadable buffer never settles, it stayed there indefinitely. + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } }) + fireEvent.blur(windows[0] as HTMLInputElement) + fireEvent.click(screen.getByText(en.resetModels)) + + const restored = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expect((restored[0] as HTMLInputElement).value).toBe('1M') + + // Reset put the draft back where it started, so Apply writes nothing at + // all rather than persisting whatever the stale text had parsed to. + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() }) + expect(mutate).not.toHaveBeenCalled() + }) + + it('settles a pasted id and refuses whitespace that would never match', async () => { + await mountSection() + fireEvent.click(screen.getByText(en.customized)) + const ids = screen.getAllByLabelText(new RegExp(en.modelId)) + fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } }) + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + // A settled id needs no second trim. + fireEvent.blur(ids[0] as HTMLInputElement) + expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash') + + // An id that is only whitespace is as absent as an empty one, and a padded + // id no longer slips past the duplicate check against its own twin. + expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' }) + expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }])) + .toEqual({ index: 1, key: 'modelIdDuplicate' }) + }) + it('renders malformed draft fallbacks without inventing catalog values', () => { render( Date: Fri, 31 Jul 2026 15:11:02 +0800 Subject: [PATCH 007/108] testing(web): pin the row card against the editor it expands into The previous assertion pinned the literal `bg-layer-3` fill that was just reverted. What matters is the relationship it broke: `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800 under the dark theme, so filling the row with either erases the nested editor's boundary. --- .../client/ui-models/tests/styles.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 478046454b..9139d01f4e 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -4,10 +4,28 @@ import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') +/** The declarations of one top-level rule, by selector. */ +function block(selector: string): string { + const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css) + if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`) + return match[1] ?? '' +} + describe('ModelsSection theme styles', () => { it('uses the shared theme tokens without light-only fallbacks', () => { + // The section once named `--border`/`--surface`/`--text-*`/`--accent-strong`, + // which nothing in this app defines, so it rendered the light-mode literals + // written as their fallbacks and stayed light under the dark theme. expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) - expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') expect(css).toContain('color: var(--dsw-alias-label-primary)') }) + + it('separates the row card from the editor it expands into', () => { + // `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800 + // under the dark theme, so filling the row with either erases the nested + // editor's boundary. The row is outlined; the fill is the editor's alone. + expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)') + expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)') + expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) + }) }) From f12d4f698e53456eb768b507f1e7fdb64b7349b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 15:11:11 +0800 Subject: [PATCH 008/108] doc(web): own the Web/TUI split on an unlisted current model The TUI still renders the unlisted current model as its own row and marks it current; Web shows the unset trigger label and asks for a replacement. The note recorded the Host decision but not that the two surfaces present it differently, which reads as a missed migration rather than a choice. Also records why `trigger.selectAria` and `trigger.fallback` hold identical strings, so a future coalescing cleanup does not merge them. --- .../2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../2026-07-24-web-session-model-selector.zh.md | 2 +- packages/client/ui-model/src/client/locales.ts | 10 +++++++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 898a0142fb..f79f7d9955 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: ca13bebbb49aec5deabd147ed1c2a8f3b246ca42 -2026-07-24-web-session-model-selector.zh.md: 16c7d773b0eea82bf991bc17c44b26c8e820aba1 +2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0 +2026-07-24-web-session-model-selector.zh.md: 705b7acaf809597d1ead450e2ff5b86e0f3af049 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index ca13bebbb4..05e923fb3b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -12,7 +12,7 @@ The Web conversation displayed and sent through the Host's fixed provider/model The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: `session.models.current` is returned independently and can remain routable when absent from every group, but the Host does not synthesize an unlisted row after its provider stops advertising it. The two surfaces answer that state differently on purpose: the TUI still renders the unlisted current model as its own row and marks it current, while Web shows the unset trigger label and asks for a replacement. Web is the surface where a catalog is edited, so a target the user just deleted should read as a decision to make rather than a selection to keep; the TUI, which only picks from what exists, has no such edit to reconcile. The cost is real and accepted — a Web composer showing the unset label can still send to the routed target — and the divergence is deliberate, not a missed migration. Exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 16c7d773b0..705b7acaf8 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -12,7 +12,7 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门有意对这一状态给出不同回答:TUI 仍把未列出的当前模型渲染为独立一行并标记为当前,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门,因此用户刚刚删除的目标应当读作一个有待作出的决定,而不是一项可以保留的选择;TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index 8b83870c40..f2b8bf1f01 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -1,4 +1,12 @@ -/** `model` namespace dictionaries. */ +/** + * `model` namespace dictionaries. + * + * `trigger.selectAria` reads identically to `trigger.fallback` today and is + * still a separate key: the visible fallback label and the accessible name of + * an unset trigger are free to diverge per locale, and folding it into + * `trigger.aria` would announce the degenerate "Select model, current Select + * model". + */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { From 00e08d1ec034e7d3389598823d81a2a22b71e738 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 15:19:13 +0800 Subject: [PATCH 009/108] testing(web): open the settings dialog in the model catalog test The test inherited an open dialog with the DeepSeek editor already expanded from the credential test above it. That test now reloads the page to exercise the welcome step, so nothing carries the dialog across and the catalog test timed out looking for a fold that was no longer on screen. The review flagged this coupling as two links deep; the merge proved it. --- apps/web/tests/onboarding-deepseek-config.e2e.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index e3fd668343..5f24ca5a6d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -162,7 +162,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models')) + // Opened here rather than inherited: the credential test reloads the page + // to exercise the welcome step, so nothing carries an open dialog across. + await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() + await deepSeek.waitFor({ timeout: 10_000 }) + await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() await settings.getByRole('button', { name: '删除模型' }).first().click() await settings.getByRole('button', { name: '添加模型' }).click() From 8c6179d69d9da06bb14d4babe746fdfd07aac8f5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 18:48:17 +0800 Subject: [PATCH 010/108] feat(pwsh): add the pwsh-local executor and the pwsh tool Windows-native execution foundation: PwshLocalExecutor implements the bash executor seam over ctx.subprocess (pwsh -NoLogo -NoProfile -NonInteractive -Command, one argv element, no quoting layer; resolvePwshPath probes PowerShell 7 / PATH / Windows PowerShell 5.1 as a pure function), and tool-pwsh is the minimal PowerShell-dialect model-facing tool over ctx.bash (foreground only, managed DSH_* env, timeout/signal/exit markers, terminal and generic presenters). Both packages carry full suites (real pwsh, self-skipping without it) at per-file 100% coverage; vitest's Windows exclusion narrows from packages/bash/* to the bash-requiring packages so the pwsh suites run natively on Windows too. The CLI gains the workspace deps and tsconfig projects without mounting either plugin; the Windows-default roadmap is recorded as a proposed Agent Note. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 6 + .../2026-08-01-pwsh-tool-and-executor.md | 35 ++ .../2026-08-01-pwsh-tool-and-executor.zh.md | 35 ++ .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 + .../2026-08-01-windows-pwsh-default.md | 41 ++ .../2026-08-01-windows-pwsh-default.zh.md | 41 ++ apps/cli/package.json | 2 + docs/config-catalog.md | 44 ++ docs/module-graph.md | 16 + docs/tool-catalog.md | 39 ++ knip.json | 1 + packages/bash/pwsh-local/README.i18n.yaml | 6 + packages/bash/pwsh-local/README.md | 53 +++ packages/bash/pwsh-local/README.zh.md | 53 +++ packages/bash/pwsh-local/package.json | 47 ++ packages/bash/pwsh-local/src/index.ts | 316 ++++++++++++++ packages/bash/pwsh-local/src/invariant.ts | 30 ++ .../bash/pwsh-local/tests/executor.spec.ts | 412 ++++++++++++++++++ packages/bash/pwsh-local/tsconfig.json | 36 ++ packages/bash/tool-pwsh/README.i18n.yaml | 6 + packages/bash/tool-pwsh/README.md | 107 +++++ packages/bash/tool-pwsh/README.zh.md | 107 +++++ packages/bash/tool-pwsh/package.json | 56 +++ packages/bash/tool-pwsh/src/index.ts | 254 +++++++++++ packages/bash/tool-pwsh/src/invariant.ts | 30 ++ .../bash/tool-pwsh/tests/integration.spec.ts | 119 +++++ packages/bash/tool-pwsh/tests/tools.spec.ts | 296 +++++++++++++ packages/bash/tool-pwsh/tsconfig.json | 45 ++ pnpm-lock.yaml | 71 +++ scripts/gen-tool-catalog.ts | 19 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + vitest.config.ts | 8 +- 34 files changed, 2341 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md create mode 100644 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md create mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md create mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md create mode 100644 packages/bash/pwsh-local/README.i18n.yaml create mode 100644 packages/bash/pwsh-local/README.md create mode 100644 packages/bash/pwsh-local/README.zh.md create mode 100644 packages/bash/pwsh-local/package.json create mode 100644 packages/bash/pwsh-local/src/index.ts create mode 100644 packages/bash/pwsh-local/src/invariant.ts create mode 100644 packages/bash/pwsh-local/tests/executor.spec.ts create mode 100644 packages/bash/pwsh-local/tsconfig.json create mode 100644 packages/bash/tool-pwsh/README.i18n.yaml create mode 100644 packages/bash/tool-pwsh/README.md create mode 100644 packages/bash/tool-pwsh/README.zh.md create mode 100644 packages/bash/tool-pwsh/package.json create mode 100644 packages/bash/tool-pwsh/src/index.ts create mode 100644 packages/bash/tool-pwsh/src/invariant.ts create mode 100644 packages/bash/tool-pwsh/tests/integration.spec.ts create mode 100644 packages/bash/tool-pwsh/tests/tools.spec.ts create mode 100644 packages/bash/tool-pwsh/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml new file mode 100644 index 0000000000..4ced84e22c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +2026-08-01-pwsh-tool-and-executor.md: fd73e929804045d7b810a587c6f088b2f7cff9cc +2026-08-01-pwsh-tool-and-executor.zh.md: f55be1ad0e102311d09b8b7ee1a003778e679cb2 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md new file mode 100644 index 0000000000..fd73e92980 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell executor and pwsh tool + +Status: implemented + +English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) + +## Problem + +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. + +## Decision + +Two new packages under `packages/bash/`: + +- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. +- **`@deepseek-ai/dsh-tool-pwsh`** — the minimal model-facing tool over `ctx.bash`, PowerShell-dialect by contract: foreground only, no `run_in_background`, no sandbox escalation, managed `DSH_*` environment (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`), result markers `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`, and `terminal`/`generic` UI presenters. + +Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. + +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). + +## Alternatives considered + +**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. + +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. + +**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. + +## Consequences + +- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. +- `tool-pwsh` is the model-visible Windows-first profile: no background tasks or escalation to mislead a model into assuming bash-tool parity, and the prompt guidance pins the `[exit code: N]` contract. +- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. +- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md new file mode 100644 index 0000000000..f55be1ad0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -0,0 +1,35 @@ +# Agent Note: PowerShell 执行器与 pwsh 工具 + +Status: implemented + +[English](2026-08-01-pwsh-tool-and-executor.md) | 中文 + +## 问题 + +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 + +## 决策 + +在 `packages/bash/` 下新增两个包: + +- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的最小面向模型工具,契约是 PowerShell 方言:仅前台,没有 `run_in_background`,没有沙箱升级,受管 `DSH_*` 环境(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`),结果标记 `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`,以及 `terminal`/`generic` UI presenter。 + +Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 + +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 + +## 备选方案 + +**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 + +**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 + +**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 + +## 后果 + +- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 +- `tool-pwsh` 是模型可见的 Windows 优先画像:没有后台任务或升级会让模型误以为与 bash 工具对等,提示词指导钉住 `[exit code: N]` 契约。 +- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 +- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..882e7478d3 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -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 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: 6f3e48f33d98b2d2da7bd288d42a0d2763163ba3 +2026-08-01-windows-pwsh-default.zh.md: 270fd8d95c85400c302540376932228a6023447c diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..6f3e48f33d --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,41 @@ +# Agent Note: Windows defaults to pwsh (roadmap) + +Status: proposed + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a minimal `pwsh` tool — but nothing yet defaults Windows hosts to them. + +## Proposal + +Three follow-up stages, each independently shippable: + +1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. +2. **Bash-tool parity twin** — `tool-pwsh` grows the bash tool's missing surface where Windows workflows prove it: `run_in_background` through the generic task runtime, and the persistence-side `DSH_SESSION_JSONL` environment fact. Sandbox escalation stays out until a Windows-confining executor exists. +3. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. + +The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then tool parity, then rendering. Nothing in this proposal changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. + +**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. + +## Acceptance criteria + +- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. +- POSIX hosts are byte-for-byte unaffected (same roster, same executor). +- The shipped-composition e2es assert the platform-gated roster on both families. +- Stage 2 lands with task-runtime integration tests; stage 3 lands with TUI/Web rendering snapshots for pwsh output. + +## Risks + +- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. +- **Tool-behavior drift** — a minimal `tool-pwsh` that never grows parity invites models to write bash-shaped commands; the prompt guidance and dialect contract mitigate this only if the twin keeps pace. +- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. +- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 3) keeps stage 1 shippable without UI churn. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..270fd8d95c --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Windows 默认改用 pwsh(路线图) + +Status: proposed + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与最小化的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 + +## 提案 + +三个阶段,各自可独立交付: + +1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 +2. **bash 工具对等孪生**——在 Windows 工作流证明需要的地方,`tool-pwsh` 补齐 bash 工具缺失的表面:经由通用任务运行时的 `run_in_background`,以及持久化侧 `DSH_SESSION_JSONL` 环境实情。在出现 Windows 约束执行器之前,沙箱升级保持缺席。 +3. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 + +各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再工具对等,最后渲染。本提案不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 + +**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 + +## 验收标准 + +- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 +- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 +- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 +- 阶段 2 附带任务运行时集成测试落地;阶段 3 附带 pwsh 输出的 TUI/Web 渲染快照落地。 + +## 风险 + +- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 +- **工具行为漂移**——永远不补齐对等的 `tool-pwsh` 会诱使模型写 bash 形状的命令;只有当孪生跟上节奏时,提示词指导与方言契约才能缓解这一点。 +- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 +- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 3)让阶段 1 无需 UI 翻动即可交付。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 2269ceaffb..8d90f864e8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -120,6 +121,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 20f835bb7f..46e563c323 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -963,6 +963,36 @@ export interface Config { Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +## `@deepseek-ai/dsh-pwsh-local` + +Requires: `subprocess` + +```ts config-catalog +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations are probed first (PowerShell 7, then Windows PowerShell 5.1), + * falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} +``` + +Source: [`packages/bash/pwsh-local/src/index.ts:43`](../packages/bash/pwsh-local/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1788,6 +1818,20 @@ export interface Config { Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh` + +Requires: `tools` · `bash` · `systemPrompt` + +```ts config-catalog +/** Plugin config (currently empty; kept as a schema so deployments can grow it). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts:31`](../packages/bash/tool-pwsh/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/module-graph.md b/docs/module-graph.md index 87789c9edb..090aac7301 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -40,7 +40,9 @@ flowchart TD pkg_bash["bash"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] + pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -505,6 +507,10 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -704,6 +710,14 @@ flowchart TD pkg_tool_bash --> pkg_tasks pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_paths + pkg_tool_pwsh --> pkg_session_persistence + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1134,6 +1148,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1175,6 +1190,7 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index d19a7adf52..a00a2876a8 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -205,6 +206,44 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. +## `@deepseek-ai/dsh-tool-pwsh` + +### `pwsh` + +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]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) + +The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. + ## `@deepseek-ai/dsh-tool-cordis` ### `cordis_inspect` diff --git a/knip.json b/knip.json index 30b1821cd3..b64e9cc403 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "pwsh", "python3", "sandbox-exec", "taskkill" diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml new file mode 100644 index 0000000000..836ab2dd84 --- /dev/null +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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/bash/pwsh-local/README.md +README.md: a97612ab4e11bf4a3fcfb77daf0624a894b02ad4 +README.zh.md: d6751dac6df789eec9727c1380f1a4c91da60728 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md new file mode 100644 index 0000000000..a97612ab4e --- /dev/null +++ b/packages/bash/pwsh-local/README.md @@ -0,0 +1,53 @@ +# @deepseek-ai/dsh-pwsh-local + +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 ` 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. + +The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, and the pure `resolvePwshPath`/`candidatePwshPaths` helpers. + +## Config + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## Behavior (and where it came from) + +The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: + +- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. +- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. + +## Model Experience + +Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands. +- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. + +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md new file mode 100644 index 0000000000..d6751dac6d --- /dev/null +++ b/packages/bash/pwsh-local/README.zh.md @@ -0,0 +1,53 @@ +# @deepseek-ai/dsh-pwsh-local + +[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 `,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。 + +命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。 + +包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`,以及纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + cwd: C:\path\to\workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace + pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH +``` + +## 行为(及其由来) + +作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: + +- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **可执行文件解析**——`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`。进程树终止(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;超时/取消分类与平台无关。 +- **面向模型的终端环境**——`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) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 + +## 模型体验 + +间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +无直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知局限与延期工作 + +- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。 +- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。 +- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 +- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 + +清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json new file mode 100644 index 0000000000..b7a63188b9 --- /dev/null +++ b/packages/bash/pwsh-local/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-local", + "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts new file mode 100644 index 0000000000..2bf8b904c2 --- /dev/null +++ b/packages/bash/pwsh-local/src/index.ts @@ -0,0 +1,316 @@ +/** + * Local PowerShell implementation of the bash executor seam. Each command runs + * as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` in a managed + * process spawned through `ctx.subprocess`; the executor owns command + * defaulting, deadlines and cause classification, the model-friendly terminal + * environment, and the model-facing stdout/stderr merge for background reads. + * + * The command string is passed 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. + * + * @module @deepseek-ai/dsh-pwsh-local + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { Context } from 'cordis' +import z from 'schemastery' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' + +/** + * Model-friendly environment overrides for PowerShell: disable colors and + * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is + * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** Default working directory for commands (default: process.cwd()). */ + cwd?: string + /** Default foreground timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for per-call timeout overrides. */ + maxTimeoutMs?: number + /** Per-stream in-memory output cap; overflow spills to a temp file. */ + maxOutputBytes?: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ + graceMs?: number + /** + * Explicit pwsh executable. When omitted, well-known Windows install + * locations are probed first (PowerShell 7, then Windows PowerShell 5.1), + * falling back to a bare `pwsh` resolved through PATH. + */ + pwshPath?: string +} + +/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ +type ResolvedConfig = Required> & Pick + +/** + * Well-known Windows PowerShell install locations plus PATH entries, newest + * first. Explicitly parameterized (env) so resolution is a pure function of + * its inputs on every platform. + * @param env - the environment to probe; defaults to the process environment. + * @returns candidate `pwsh` executable paths in resolution order. + */ +export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { + const programFiles = env.ProgramFiles ?? 'C:\\Program Files' + const systemRoot = env.SystemRoot ?? 'C:\\Windows' + const candidates = [ + join(programFiles, 'PowerShell', '7', 'pwsh.exe'), + ] + // Microsoft Store installs (and any user-added location) live on PATH; + // entries may carry surrounding quotes from `setx`-style definitions. + for (const entry of (env.PATH ?? '').split(';')) { + const trimmed = entry.trim().replace(/^"|"$/g, '') + if (trimmed.length === 0) continue + candidates.push(join(trimmed, 'pwsh.exe')) + } + // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. + candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + return candidates +} + +/** + * Resolve the pwsh executable this executor spawns. + * @param configured - an explicit `pwshPath` config value, trusted as-is. + * @param env - the environment to probe on Windows; defaults to the process environment. + * @param platform - the platform to resolve for; defaults to the process platform. + * @returns the first existing well-known location on Windows (PowerShell 7 + * install, a PATH entry such as the Microsoft Store install, then Windows + * PowerShell 5.1), else `pwsh` for PATH resolution. + */ +export function resolvePwshPath( + configured?: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (configured !== undefined && configured.length > 0) return configured + if (platform === 'win32') { + for (const candidate of candidatePwshPaths(env)) { + if (existsSync(candidate)) return candidate + } + } + return 'pwsh' +} + +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`pwsh-local: ${name} must be a positive finite number`) + } +} + +/** + * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill + * files, and process-tree termination are the subprocess service's mechanics; + * this executor supplies their configured budgets per spawn. + */ +export class PwshLocalExecutor extends BashExecutor { + static inject = ['subprocess'] + + static Config: z = z.object({ + cwd: z.string(), + timeoutMs: z.number().default(120_000), + maxTimeoutMs: z.number().default(600_000), + maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), + graceMs: z.number().default(DEFAULT_GRACE_MS), + pwshPath: z.string(), + }) + + /** Validated config (schemastery applied the defaults before construction). */ + readonly config: ResolvedConfig + + /** The pwsh executable resolved once at construction. */ + readonly pwshPath: string + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery fills these fields before construction; the type does not encode that step. + this.config = config as ResolvedConfig + assertPositiveFinite('timeoutMs', this.config.timeoutMs) + assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) + assertPositiveFinite('graceMs', this.config.graceMs) + this.pwshPath = resolvePwshPath(this.config.pwshPath) + } + + /** + * Resolve a request into a fully-specified spec: fill `workdir` from + * `config.cwd` (else `process.cwd()`), and `timeoutMs` from + * `config.timeoutMs`, capped at `config.maxTimeoutMs`. + */ + resolve(request: BashExecRequest): BashExecSpec { + const timeoutMs = clampTimeout( + request.timeoutMs, + this.config.timeoutMs, + this.config.maxTimeoutMs, + 'pwsh-local: request.timeoutMs', + ) + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) + return { + command: request.command, + workdir: request.workdir ?? this.config.cwd ?? process.cwd(), + timeoutMs, + stdoutMaxBytes, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + + async run(spec: BashExecSpec): Promise { + // 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 outcome = await handle.done + const collected = PwshLocalExecutor.collected(handle) + // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined + const aborted = d.signal.aborted && !timedOut + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } + } + + start(spec: BashExecSpec): 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 collected = PwshLocalExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } + + let stdoutOffset = 0 + let stderrOffset = 0 + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done: running.done.then((outcome) => { + // Any signal termination is killed, including a command signaling itself. + if (proc.status === 'running') { + proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed' + } + proc.exitCode = outcome.exitCode + proc.signal = outcome.signal + this.onProcessDone(proc, collected.stderr.readFrom(0).text) + }, (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) + }), + readOutput: (): BashProcessRead => { + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) + stdoutOffset = out.nextOffset + stderrOffset = err.nextOffset + + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // Single newline between sections: stdout chunks usually end with one + // already; add it only when missing. + const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' + const delta = out.text + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') + return { + delta, + lossy: out.lossy || err.lossy, + ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}, + ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}, + } + }, + kill: (): boolean => { + if (proc.status !== 'running') return false + proc.status = 'killed' + running.terminate() + return true + }, + } + return proc + } + + /** + * Settlement hook for subclasses that attach execution facts to a process. + * The base implementation is intentionally empty. + * @param _proc - the settled process handle. + * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + */ + protected onProcessDone(_proc: BashProcess, _stderr: string): void {} +} + +export default PwshLocalExecutor diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts new file mode 100644 index 0000000000..4bb1c1ea30 --- /dev/null +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`. + * @module @deepseek-ai/dsh-pwsh-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-local-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 seam. + */ +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 */ diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts new file mode 100644 index 0000000000..cd72e47db4 --- /dev/null +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -0,0 +1,412 @@ +/** + * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess + * service plus a REAL pwsh executable, exercised through the executor seam + * (`resolve` → `run`/`start`). These verify the world — actual PowerShell + * runs, output capture, truncation and spill, deadlines, kill escalation, and + * the background-handle contract. The suite self-skips when no `pwsh` is on + * PATH (a CI accommodation for hosts without PowerShell); the pure unit tests + * (config validation, executable resolution) run on every platform. PowerShell + * writes CRLF on Windows, so exact text assertions normalize line endings. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { PwshLocalExecutor, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) + +const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +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) + return norm(actual) === norm(expected) +} + +async function setup(config: ConstructorParameters[1] = {}) { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config }) + const bash = ctx.bash as PwshLocalExecutor + return { ctx, bash } +} + +/** + * Poll a handle's consuming readOutput until the ACCUMULATED delta contains + * `expected`; returns the accumulation (reads never re-deliver, so the caller + * gets everything produced up to the match). + */ +async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + let all = '' + while (Date.now() < deadline) { + all += proc.readOutput().delta + if (lf(all).includes(expected)) return lf(all) + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`) +} + +describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => { + it('trusts an explicit configured path verbatim', () => { + expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe') + expect(resolvePwshPath('pwsh')).toBe('pwsh') + }) + + 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') + }) + + it('returns pwsh on non-Windows platforms regardless of the environment', () => { + expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh') + expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') + }) + + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { + const candidates = candidatePwshPaths({ + ProgramFiles: 'P:\\Program Files', + SystemRoot: 'S:\\Windows', + PATH: ';"Q:\\quoted store";' + ';', + }) + expect(candidates).toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('Q:\\quoted store', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + // A missing PATH contributes no entries (the empty-string fallback). + expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' })) + .toEqual([ + join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + + it('returns the first EXISTING win32 candidate, else pwsh', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'pwsh.exe'), '') + // The existing PATH entry wins over the non-existent Program Files install. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(join(store, 'pwsh.exe')) + // No candidate exists anywhere (SystemRoot points at a non-existent tree, + // so even the Windows PowerShell 5.1 fallback cannot exist) → the + // PATH-resolution fallback. + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) + .toBe('pwsh') + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { + it('resolves with output and the effective timeout', async () => { + const { bash } = await setup({ timeoutMs: 5_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) + }) + + it('uses config cwd, overridable per call', async () => { + const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) + const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + const { bash } = await setup({ cwd: first }) + const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) + const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second })) + expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true) + }) + + it('defaults cwd to process.cwd()', async () => { + const { bash } = await setup() + const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) + expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true) + }) + + it('caps per-call timeouts at maxTimeoutMs', async () => { + const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 }) + const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 })) + expect(result.timeoutMs).toBe(2_000) + }) + + it('rejects invalid numeric config and timeout overrides', async () => { + await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) + await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) + await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + + const { bash } = await setup() + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/) + expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/) + }) + + it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100) + + // Raw Console writes avoid PowerShell's own line-ending and formatting + // layers, so the byte counts are exact on every platform. + const result = await bash.run(bash.resolve({ + command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)', + stdoutMaxBytes: 500, + })) + + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stdout.truncated).toBe(false) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 })) + expect(result.timedOut).toBe(true) + // Mutually exclusive: a timeout classifies as timedOut, never also aborted. + expect(result.aborted).toBe(false) + expect(result.timeoutMs).toBe(100) + }) + + it('propagates abort signals', async () => { + const { bash } = await setup() + const controller = new AbortController() + const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.aborted).toBe(true) + // Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut. + expect(result.timedOut).toBe(false) + }) + + it('classifies a self-killed command as neither timed out nor aborted', async () => { + const { bash } = await setup({ timeoutMs: 60_000 }) + const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' })) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + // Windows reports a forced termination without a signal; POSIX reports SIGTERM. + if (process.platform === 'win32') { + expect(result.signal).toBeNull() + } else { + expect(result.signal).toBe('SIGTERM') + } + }) + + it('rejects on spawn failure (bad workdir)', async () => { + const { bash } = await setup() + await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) + }) + + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { + const { bash } = await setup() + const spec = bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. + expect(spec.stdin).toBe('piped\n') + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) + const result = await bash.run(spec) + expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n') + }) + + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'Write-Output ok' }) + expect('stdin' in spec).toBe(false) + expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) + }) +}) + +describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => { + it('start returns immediately with a running handle that settles as completed', async () => { + const { bash } = await setup() + const before = Date.now() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(150) + expect(proc.status).toBe('running') + await proc.done + expect(proc.status).toBe('completed') + expect(proc.exitCode).toBe(0) + }) + + it('threads stdin and extra env into a background process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ + command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"', + stdin: 'bg-stdin\n', + 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') + await proc.done + expect(proc.exitCode).toBe(0) + }) + + it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' })) + const first = await readUntil(proc, 'first\n') + expect(lf(first)).toBe('first\n') + await proc.done + // Read-after-exit returns the remaining buffered output — once. + const second = proc.readOutput() + expect(lf(second.delta)).toBe('second\n') + expect(second.lossy).toBe(false) + expect(proc.readOutput().delta).toBe('') + }) + + it('readOutput marks stderr sections', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput reports stderr-only deltas without a leading newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n') + }) + + it('readOutput adds a separator only when stdout lacks a trailing newline', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' })) + await proc.done + expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n') + }) + + it('readOutput flags lossy reads and reports stdout spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' })) + await proc.done + const read = proc.readOutput() + // Window slid past offset 0 → lossy, spill path points at the full stream. + expect(read.lossy).toBe(true) + expect(read.stdoutSpillPath).toBeDefined() + }) + + it('readOutput reports stderr spill paths', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' })) + await proc.done + const read = proc.readOutput() + expect(read.lossy).toBe(true) + expect(read.stderrSpillPath).toBeDefined() + expect(lf(read.delta)).toContain('[stderr]') + }) + + it('kill() terminates the process tree: true once, false after settlement', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + expect(proc.kill()).toBe(true) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.kill()).toBe(false) + }) + + it('kill() returns false for a naturally completed process', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok' })) + await proc.done + expect(proc.status).toBe('completed') + expect(proc.kill()).toBe(false) + }) + + it('a spec.signal abort settles the handle as killed, not completed', async () => { + const { bash } = await setup() + const controller = new AbortController() + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal })) + controller.abort() + await proc.done + expect(proc.status).toBe('killed') + }) + + it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' })) + await proc.done + expect(proc.status).toBe('killed') + expect(proc.exitCode).toBeNull() + expect(proc.signal).toBe('SIGTERM') + }) + + it('a background spawn failure settles as killed with the error readable on stderr', async () => { + const { bash } = await setup() + const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' })) + // done resolves (never rejects) even though the process never ran. + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('spawn failed:') + }) +}) + +describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + // The child prints its own pid so the test can probe liveness through the + // public read surface alone. + const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' })) + const pid = Number((await readUntil(proc, '\n')).trim()) + expect(Number.isInteger(pid) && pid > 0).toBe(true) + + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() + expect(() => process.kill(pid, 0)).toThrow() + await proc.done + // POSIX reports the kill as a signal; Windows reports a forced + // termination as exit 1 with no signal (indistinguishable from a crash), + // so the status stamp follows the platform's exit facts. + expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) + + it('service disposal settles running handles and leaves settled ones untouched', async () => { + const ctx = new Context() + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(PwshLocalExecutor, { graceMs: 200 }) + const bash = ctx.bash as PwshLocalExecutor + + const finished = bash.start(bash.resolve({ command: 'Write-Output done' })) + await finished.done + expect(finished.status).toBe('completed') + const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) + + await managerFiber.dispose() + // A settled process was untouched; the live one was terminated and joined. + expect(finished.status).toBe('completed') + await running.done + expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed') + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json new file mode 100644 index 0000000000..53ccc94926 --- /dev/null +++ b/packages/bash/pwsh-local/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml new file mode 100644 index 0000000000..e16107e42f --- /dev/null +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -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/bash/tool-pwsh/README.md +README.md: 4f1d62dbf49fef678e3285776c466286535d66da +README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md new file mode 100644 index 0000000000..4f1d62dbf4 --- /dev/null +++ b/packages/bash/tool-pwsh/README.md @@ -0,0 +1,107 @@ +# @deepseek-ai/dsh-tool-pwsh + +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. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin. + +Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). + +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests. + +The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. + +## Tools + +### `pwsh` + +| Arg | Type | Notes | +|---|---|---| +| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. | +| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | +| `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. | + +`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()`. + +### Managed shell environment + +Every call receives a freshly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. + +Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. + +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text. + +## UI presentation + +The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe. + +## Model Experience + +### System prompt + +#### What the model sees + +Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section. + +##### Pwsh guidance + +```markdown +Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +``` + +#### Token effect + +Small fixed input cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section. + +### Tool schemas + +#### What the model sees + +The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent. + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token. + +### Foreground result + +#### What the model sees + +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`. + +#### Token effect + +Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +### Tool errors + +#### What the model sees + +Validation and infrastructure failures are normalized as `Error: `. 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 `, and `tool call aborted`. + +#### Token effect + +Only the failing call adds these retained tokens; an aborted call adds no command output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin. +- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin. +- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. +- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md new file mode 100644 index 0000000000..bbeece3c64 --- /dev/null +++ b/packages/bash/tool-pwsh/README.zh.md @@ -0,0 +1,107 @@ +# @deepseek-ai/dsh-tool-pwsh + +[English](README.md) | 中文 + +面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。 + +需要一个已加载的执行器实现;插件在 `ctx.bash` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。 + +包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。 + +该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。 + +## 工具 + +### `pwsh` + +| 参数 | 类型 | 说明 | +|---|---|---| +| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | + +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。 + +### 受管 shell 环境 + +每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。 + +结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。 + +规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。 + +## UI 呈现 + +工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。 + +##### Pwsh 指导 + +```markdown +Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +``` + +#### Token 影响 + +插件激活期间每个请求有少量固定输入成本。 + +#### KV Cache 影响 + +注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。 + +#### Token 影响 + +工具可见时每个请求有固定的 schema 成本。 + +#### KV Cache 影响 + +可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。 + +### 前台结果 + +#### 模型看到的内容 + +渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`。 + +#### Token 影响 + +调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。 + +#### KV Cache 影响 + +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 + +### 工具错误 + +#### 模型看到的内容 + +校验与基础设施失败被规范化为 `Error: `。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got ` 与 `tool call aborted`。 + +#### Token 影响 + +只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。 + +#### KV Cache 影响 + +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 + +## 已知局限与延期工作 + +- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。 +- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。 +- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 +- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json new file mode 100644 index 0000000000..90e12438d8 --- /dev/null +++ b/packages/bash/tool-pwsh/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh", + "description": "Model-facing pwsh tool over the bash executor seam", + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts new file mode 100644 index 0000000000..96073fcf93 --- /dev/null +++ b/packages/bash/tool-pwsh/src/index.ts @@ -0,0 +1,254 @@ +/** + * Model-facing `pwsh` tool 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. + * + * Minimal by design: no background tasks, no sandbox escalation — this is the + * "works on my Windows machine" profile until the full bash-tool feature set + * gets a PowerShell twin. + * + * @module @deepseek-ai/dsh-tool-pwsh + */ + +import { isAbsolute, resolve as resolvePath } from 'node:path' +import { Context } from 'cordis' +import z from 'schemastery' +import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import type { 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-session-persistence' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' + +export const name = 'tool-pwsh' +export const inject = ['tools', 'bash', 'systemPrompt'] + +/** Plugin config (currently empty; kept as a schema so deployments can grow it). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} + +/** Runtime configuration schema for the pwsh tool plugin. */ +export const Config: z = z.object({ + dshHome: z.string(), +}) + +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ +interface PwshToolArgs { + command: string + description: string + timeoutMs?: number + workdir?: string +} + +/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ +interface PwshForegroundResult { + kind: 'foreground' + exitCode: number | null + signal: NodeJS.Signals | null + timedOut: boolean + aborted: boolean + timeoutMs: number + stdout: { text: string; truncated: boolean; spillPath?: string } + stderr: { text: string; truncated: boolean; spillPath?: string } +} + +function validatePwshArgs(args: PwshToolArgs): void { + if (args.command.trim().length === 0) { + throw new Error('invalid command: expected a non-empty string') + } + if (args.description.trim().length === 0) { + throw new Error('invalid description: expected a non-empty string') + } + 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)}`) + } +} + +function pwshDescription(): string { + return '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]`. ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.' +} + +/** + * Resolve an explicit workdir first, making a relative one session-workspace-relative; + * otherwise use the session header cwd and leave executor defaulting as the fallback. + */ +function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { + const headerCwd = exec.agent?.session.header.cwd + if (modelWorkdir === undefined) return headerCwd + if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) { + return resolvePath(headerCwd, modelWorkdir) + } + return modelWorkdir +} + +/** + * The model-facing text of one foreground pwsh result: stdout, a marked + * stderr section, then the applicable timeout, signal, and exit markers — + * each separated by a newline only when the accumulated text lacks one, so a + * trailing newline in stdout never produces a blank line. + * + * @param value - the canonical foreground result (the schema-derived value shape). + * @returns the model-facing text. + */ +function renderPwshOutput(value: RenderablePwshOutput): string { + let rendered = value.stdout.text + const marker = (line: string): void => { + rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line + } + if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`) + if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`) + if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`) + if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`) + return rendered +} + +/** + * Detach the executor DTO from readonly seam interfaces into plain JSON data. + * @param result - the executor's run outcome. + * @returns the canonical foreground result the tool returns and renders. + */ +function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + kind: 'foreground', + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + stdout: output(result.stdout), + stderr: output(result.stderr), + } +} + +/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */ +interface RenderablePwshOutput { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: { text: string } + stderr: { text: string } +} + +/** + * The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell + * marker, and the session identity when an agent is present. + */ +function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: dshHome, + [`${DSH_ENV_PREFIX}SHELL`]: '1', + } + if (exec.agent !== undefined) { + values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id + } + return values +} + +export function apply(ctx: Context, config: Config = {}): void { + const dshHome = resolveDshHome(config.dshHome) + + ctx.systemPrompt.section({ + name: 'tool:pwsh', + order: 105, + text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.', + }) + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: pwshDescription(), + parameters: { + command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, + description: { + type: 'string', + required: true, + description: 'Clear, concise description of what this command does in active voice, ' + + '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; ' + + '"git status" → "Show working tree status"; "Get-Process" → "List running processes".', + }, + timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, + workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: renderPwshOutput(value), + }], + }, + async execute(args: PwshToolArgs, exec) { + validatePwshArgs(args) + const workdir = resolveWorkdir(args.workdir, exec) + const result = await ctx.bash.run(ctx.bash.resolve({ + command: args.command, + ...workdir !== undefined ? { workdir } : {}, + ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv: collectDshEnv(exec, dshHome), + signal: exec.signal, + })) + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + return canonicalPwshResult(result) + }, + presentCall: (args: PwshToolArgs): TerminalCallView => ({ + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, + }), + presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { + const block = result.content.length === 1 ? result.content[0] : undefined + if (block === undefined || block.type !== 'text') return undefined + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] } + }, + })) +} diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts new file mode 100644 index 0000000000..dd6370b490 --- /dev/null +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`. + * @module @deepseek-ai/dsh-tool-pwsh/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-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 seam. + */ +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 */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts new file mode 100644 index 0000000000..c703c6aa0e --- /dev/null +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -0,0 +1,119 @@ +/** + * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the + * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell + * process. These verify the world — actual commands run, stdout/stderr come + * back, exit codes render, timeouts abort, and per-session cwd resolution + * works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation + * for hosts without PowerShell); the fake-executor suite (tools.spec.ts) + * carries the coverage gate. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' + +const testToolSignal = new AbortController().signal + +const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + +let dir: string +let ctx: Context + +let callCounter = 0 +function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) { + return ctx.tools.execute({ + signal: signal ?? testToolSignal, + callId: CallId(`it-${++callCounter}`), + name, + arguments: args, + ...agentObj ? { agent: agentObj as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-')) + await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n') + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) + await ctx.plugin(ToolPwsh) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + + it('runs a command and returns stdout with the exit marker', async () => { + const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) + expect(lf(text(result))).toBe('hi\n[exit code: 0]') + }) + + it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { + const result = await call('pwsh', { + command: '[Console]::Error.WriteLine("boom"); exit 3', + description: 'fail loudly', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]') + }) + + it('resolves relative paths in the session workspace', async () => { + const result = await call('pwsh', { + command: 'Get-Content greeting.txt', + description: 'read greeting', + }, agent()) + expect(result.isError).toBe(false) + expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]') + }) + + it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { + const result = await call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + timeoutMs: 100, + }, agent()) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected a timed-out foreground result') + expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false }) + // Windows reports the forced termination as exit 1 without a signal; + // POSIX reports SIGTERM — the timeout marker is the stable fact. + expect(lf(text(result))).toContain('[timed out after 100ms]') + }) + + it('an upstream cancellation aborts the run', async () => { + const controller = new AbortController() + const pending = call('pwsh', { + command: 'Start-Sleep -Seconds 60', + description: 'sleep forever', + }, agent(), controller.signal) + setTimeout(() => { controller.abort() }, 50) + const result = await pending + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts new file mode 100644 index 0000000000..5361130991 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -0,0 +1,296 @@ +/** + * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, + * exercised through `ctx.tools.execute()` so nothing bypasses the tool + * registry. The fake executor makes every seam outcome scriptable — output + * text, truncation, timeout, abort, nonzero exits — so these tests verify the + * schema, argument validation, workdir derivation, managed `DSH_*` collection, + * abort translation, canonical result projection, rendering, and the UI + * presenters. Real-pwsh behavior is pinned separately in integration.spec.ts. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve as resolvePath } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' + +const testToolSignal = new AbortController().signal + +/** + * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` + * returns the armed script, `start()` throws — the pwsh tool must NEVER create + * a background task. + */ +class FakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + specs: BashExecSpec[] = [] + startCalls = 0 + handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + + 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.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(spec: BashExecSpec): Promise { + this.specs.push(spec) + return this.handler(spec) + } + + override start(): BashProcess { + this.startCalls++ + throw new Error('the pwsh tool must never start a background task') + } +} + +/** A successful run result over the given stdout; overrides script the failure shapes. */ +function runResult(stdout: string, overrides?: Partial): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +async function setup(config: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, config) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** A stand-in agent whose session header carries the given cwd and id. */ +const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } }) + +let callCounter = 0 +function call( + ctx: Context, + name: string, + args: unknown, + options: { agent?: object; signal?: AbortSignal } = {}, +) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...options.agent ? { agent: options.agent as never } : {}, + ...options.signal ? { signal: options.signal } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('registration', () => { + it('registers the pwsh tool with its prompt section and schema', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') + expect(schema).toBeDefined() + expect(schema?.description).toContain('PowerShell command') + expect(schema?.parameters.properties).toMatchObject({ + command: { type: 'string' }, + description: { type: 'string' }, + timeoutMs: { type: 'number' }, + workdir: { type: 'string' }, + }) + expect(schema?.parameters.required).toEqual(['command', 'description']) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result') + }) + + it('stays pending until ctx.bash exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeBash) + const fiber = await ctx.plugin(ToolPwsh) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('argument validation', () => { + it('rejects a blank command or description and a non-positive timeoutMs', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') + expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) + .toContain('invalid timeoutMs: expected a positive number') + }) +}) + +describe('execution through the bash seam', () => { + it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + const { ctx, bash } = await setup({ dshHome }) + bash.handler = () => runResult('hi\n') + const result = await call(ctx, 'pwsh', { + command: 'Write-Output hi', + description: 'say hi', + timeoutMs: 1234, + }, { agent: agent('/sessions/s1') }) + expect(result.isError).toBe(false) + const request = bash.requests[0] + expect(request?.command).toBe('Write-Output hi') + expect(request?.workdir).toBe('/sessions/s1') + expect(request?.timeoutMs).toBe(1234) + expect(request?.dshEnv).toEqual({ + DSH_HOME: dshHome, + DSH_SHELL: '1', + DSH_SESSION_ID: 'session-1', + }) + expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + }) + + it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') }) + expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') }) + expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) + }) + + it('omits workdir and the session id without an agent, so executor defaulting applies', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) + expect(bash.requests[0]).not.toHaveProperty('workdir') + const dshEnv = bash.requests[0]?.dshEnv + expect(dshEnv).toBeDefined() + expect(dshEnv?.['DSH_SHELL']).toBe('1') + expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String)) + expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID') + }) + + it('forwards exec.signal into the resolved request', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal }) + expect(bash.requests[0]?.signal).toBe(controller.signal) + }) + + it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out\n', { + exitCode: 2, + stderr: { text: 'err\n', truncated: false }, + timeoutMs: 5000, + }) + const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pwsh success') + expect(result.value).toEqual({ + kind: 'foreground', + exitCode: 2, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 5000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: 'err\n', truncated: false }, + }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') + }) + + it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[exit code: 0]') + + bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) + const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) + // A timeout kill carries both facts, mirroring the bash tool's markers. + expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }) + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) + }) + + it('never starts a background task', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('ok\n') + await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) + bash.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'pwsh', { command: 'missing', description: 'missing' }) + expect(bash.startCalls).toBe(0) + }) +}) + +describe('UI presentation', () => { + it('a real execute renders the console view through the tool definition presenter', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const args = { command: 'Write-Output hi', description: 'say hi' } + const result = await call(ctx, 'pwsh', args, { agent: agent('/w') }) + const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) + expect(view).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }], + }) + }) + + it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' })) + .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' }) + expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' })) + .toMatchObject({ cwd: 'C:\\work' }) + }) + + it('presentResult falls back to undefined for multi-block or non-text content', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + const args = { command: 'Write-Output hi', description: 'say hi' } + const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false } + expect(definition?.presentResult?.(args, multi as never)).toBeUndefined() + const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false } + expect(definition?.presentResult?.(args, image as never)).toBeUndefined() + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json new file mode 100644 index 0000000000..2811462193 --- /dev/null +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b38715d30..af21e15b04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -426,6 +429,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -941,6 +947,31 @@ importers: specifier: 0.0.0-test.0 version: 0.0.0-test.0 + packages/bash/pwsh-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/bash/tool-bash: dependencies: schemastery: @@ -1011,6 +1042,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/bash/tool-pwsh: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3db63b4271..5f8272ae3f 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -17,6 +17,7 @@ import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -31,6 +32,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -167,6 +169,23 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh', + dir: 'tool-pwsh', + source: 'packages/bash/tool-pwsh/src/index.ts', + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The pwsh tool consumes the bash executor seam; the schema harvest + // mounts the pwsh-local implementation so the inject resolves without + // executing anything (registration never spawns a process). + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(PwshLocalExecutor) + await ctx.plugin(ToolPwsh) + }, + note: + 'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\\...` paths and `$env:NAME` variables.', + }, { pkg: '@deepseek-ai/dsh-tool-cordis', dir: 'tool-cordis', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4104ff8fdc..ed723c26f5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -43,6 +43,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, + 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d078202e7..213c9be688 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,8 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], + "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"], + "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"], "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"], "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index bae800f3ff..99217311e6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -136,6 +136,8 @@ { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, diff --git a/vitest.config.ts b/vitest.config.ts index 5f542feee6..0fa2a6dd53 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,13 @@ const pathsPlugin = (): ReturnType => tsconfigPaths({ proj const windowsUnsupportedPackages = process.platform === 'win32' ? [ - 'packages/bash/*', + // Bash-requiring suites (a real POSIX shell is unavailable on Windows). + // The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay + // INCLUDED: PowerShell ships with Windows, so they run natively here. + 'packages/bash/bash-local', + 'packages/bash/bash-sandbox', + 'packages/bash/tool-bash', + 'packages/bash/tool-bash-persistent', 'packages/hooks/*', 'packages/subprocess/*', 'packages/pty/pty-local', From 30c421ed75083d7d8ee5cb3c52fb32e9f64c34f1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:34:12 +0800 Subject: [PATCH 011/108] fix(pwsh): pin CI expectations for the mirror design - jscpd: the executor/tool mirror dsh-bash-local/dsh-tool-bash by design (Agent Note), so the mirrored regions carry explicit ignore markers with reasons instead of being flagged as duplication. - pwsh-local: a self-terminated process reports SIGTERM or SIGKILL on POSIX (PowerShell's Stop-Process choice), not only SIGTERM. - gen-tool-catalog.spec: the shipped-tool completeness list gains 'pwsh'. --- packages/bash/pwsh-local/src/index.ts | 2 ++ packages/bash/pwsh-local/tests/executor.spec.ts | 8 +++++--- packages/bash/tool-pwsh/src/index.ts | 9 +++++++++ packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 2bf8b904c2..8548731a28 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -22,6 +22,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ /** * Model-friendly environment overrides for PowerShell: disable colors and * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is @@ -312,5 +313,6 @@ export class PwshLocalExecutor extends BashExecutor { */ protected onProcessDone(_proc: BashProcess, _stderr: string): void {} } +/* jscpd:ignore-end */ export default PwshLocalExecutor diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index cd72e47db4..1794666911 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -197,11 +197,12 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' })) expect(result.timedOut).toBe(false) expect(result.aborted).toBe(false) - // Windows reports a forced termination without a signal; POSIX reports SIGTERM. + // Windows reports a forced termination without a signal; POSIX reports the + // terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill). if (process.platform === 'win32') { expect(result.signal).toBeNull() } else { - expect(result.signal).toBe('SIGTERM') + expect(['SIGTERM', 'SIGKILL']).toContain(result.signal) } }) @@ -347,7 +348,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' await proc.done expect(proc.status).toBe('killed') expect(proc.exitCode).toBeNull() - expect(proc.signal).toBe('SIGTERM') + // PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill. + expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal) }) it('a background spawn failure settles as killed with the error readable on stderr', async () => { diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 96073fcf93..704c35647c 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -58,6 +58,7 @@ interface PwshForegroundResult { stderr: { text: string; truncated: boolean; spillPath?: string } } +/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ function validatePwshArgs(args: PwshToolArgs): void { if (args.command.trim().length === 0) { throw new Error('invalid command: expected a non-empty string') @@ -69,6 +70,7 @@ function validatePwshArgs(args: PwshToolArgs): void { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } } +/* jscpd:ignore-end */ function pwshDescription(): string { return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' @@ -185,6 +187,10 @@ export function apply(ctx: Context, config: Config = {}): void { workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, }, 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 + // Agent Note). + /* jscpd:ignore-start -- deliberate foreground-result schema symmetry with dsh-tool-bash. */ schema: { type: 'object', additionalProperties: false, @@ -217,11 +223,13 @@ export function apply(ctx: Context, config: Config = {}): void { }, }, }, + /* jscpd:ignore-end */ render: (_args, value) => [{ type: 'text', text: renderPwshOutput(value), }], }, + /* jscpd:ignore-start -- the foreground execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) const workdir = resolveWorkdir(args.workdir, exec) @@ -239,6 +247,7 @@ export function apply(ctx: Context, config: Config = {}): void { } return canonicalPwshResult(result) }, + /* jscpd:ignore-end */ presentCall: (args: PwshToolArgs): TerminalCallView => ({ card: 'terminal', title: args.command, diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ab8bc2730..73c3e44e89 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'pwsh', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From 5ca7e1ada9f8a7f33a41f6a893421d648ad9adc8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:43:05 +0800 Subject: [PATCH 012/108] docs(config-catalog): refresh the pwsh-local source line after the jscpd markers --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a687dff035..f5caf51007 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -991,7 +991,7 @@ export interface Config { } ``` -Source: [`packages/bash/pwsh-local/src/index.ts:43`](../packages/bash/pwsh-local/src/index.ts) +Source: [`packages/bash/pwsh-local/src/index.ts:44`](../packages/bash/pwsh-local/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` From 1c3887673a8cfe1670c1c2baa7db09fba5bfed9d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:32 +0800 Subject: [PATCH 013/108] test(fs-search): re-record the glob-sampling snapshot against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario previously carried an authored fixture; W4 of #1119 review requires a live transcript. Recording surfaced two composition bugs that are fixed here alongside it: - provider ids: the app and the replay catalog both named the old 'deepseek' provider, which no adapter registers; both now use 'deepseek-official' - the live config lacked persistenceCompression: none, so record-mode sessions were written zstd-compressed and could not be harvested (the snapshot twin already forced plaintext) Recorded logs also need deterministic replay: - packChunks: false in both configs — the eager-drain batch boundaries that split packed delta runs are timing-dependent, so a packed log of a long reasoning stream cannot replay-match its live record - the fixture's request/header config and request/context are normalized to the replay-produced minimal shape (the live adapter logs model capabilities llm-replay has no data for), and tool-result path separators are canonicalized to '/' for the Linux golden posixOnly is restored now that the fixture is recorded. --- examples/acp-agent/tests/acp.snapshot.ts | 13 +- .../tests/fs-search.cordis.snapshot.yml | 8 +- examples/acp-agent/tests/fs-search.cordis.yml | 7 +- .../snapshots/fs-glob-sampling/session.jsonl | 145 +++++++++++++++--- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 277d0b20ca..abdf10e367 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -181,16 +181,23 @@ const SCENARIOS: Scenario[] = [ // `--sort=modified` order, pinning over-cap glob sampling without depending // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison - // cannot normalize. + // cannot normalize. Recorded (not authored): the assistant turn is a real + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. + // The composition disables packed chunk rows (fs-search.cordis.yml), whose + // run boundaries depend on eager-drain timing, and the recorded fixture's + // `request/header` config and `request/context` are normalized to the + // replay-produced minimal shape (the live adapter logs model capabilities + // like maxTokens/reasoningEffort that llm-replay has no data for), and its + // tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, - recorded: false, + recorded: true, + posixOnly: true, pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, prepareWorkspace: prepareFsSearchWorkspace, - posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 141691a087..5fcb2248f3 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -3,7 +3,7 @@ name: '@deepseek-ai/dsh-llm-replay' config: providers: - - id: deepseek + - id: deepseek-official name: DeepSeek models: - id: deepseek-v4-pro @@ -17,10 +17,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 153128f914..0f6d5d9a63 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -16,9 +16,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index ca51259632..f1c26ca911 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,25 +1,120 @@ -{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} -{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} -{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":20,"time":1785483398078,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1785483398078,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"ce2334a4-be71-490b-a502-29186a9ced5c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1785483398078,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1785483398079,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} +{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} +{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} +{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} +{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} +{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} +{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} +{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} +{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} +{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} +{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} +{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} +{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} +{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} +{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} +{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} +{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} +{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} +{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} +{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} +{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} +{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} +{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} +{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}} From d73888478ab3dd568f499a8b8a6e7fd9649df00f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:17:46 +0800 Subject: [PATCH 014/108] feat(bash-env): extract the shared DSH_* environment registry into its own package --- packages/bash/bash-env/README.i18n.yaml | 6 + packages/bash/bash-env/README.md | 51 ++++ packages/bash/bash-env/README.zh.md | 51 ++++ packages/bash/bash-env/package.json | 50 ++++ packages/bash/bash-env/src/index.ts | 217 ++++++++++++++++ packages/bash/bash-env/src/invariant.ts | 30 +++ packages/bash/bash-env/tests/bash-env.spec.ts | 237 ++++++++++++++++++ packages/bash/bash-env/tsconfig.json | 36 +++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- python/sdk-runtime/package.json | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + vitest.config.ts | 3 +- 14 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 packages/bash/bash-env/README.i18n.yaml create mode 100644 packages/bash/bash-env/README.md create mode 100644 packages/bash/bash-env/README.zh.md create mode 100644 packages/bash/bash-env/package.json create mode 100644 packages/bash/bash-env/src/index.ts create mode 100644 packages/bash/bash-env/src/invariant.ts create mode 100644 packages/bash/bash-env/tests/bash-env.spec.ts create mode 100644 packages/bash/bash-env/tsconfig.json diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml new file mode 100644 index 0000000000..90d43daa7b --- /dev/null +++ b/packages/bash/bash-env/README.i18n.yaml @@ -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/bash/bash-env/README.md +README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f +README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md new file mode 100644 index 0000000000..7b939326d4 --- /dev/null +++ b/packages/bash/bash-env/README.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +English | [中文](README.zh.md) + +The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly. + +The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin. + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog. diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md new file mode 100644 index 0000000000..aeb33629de --- /dev/null +++ b/packages/bash/bash-env/README.zh.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-bash-env + +[English](README.md) | 中文 + +工具无关的 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`。 + +## Config + +```yaml +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + config: + dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh +``` + +## Managed environment + +每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-bash-env' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。 + +## Model Experience + +Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。 diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json new file mode 100644 index 0000000000..9ea29ad57c --- /dev/null +++ b/packages/bash/bash-env/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-bash-env", + "description": "Tool-independent managed DSH_* shell environment registry", + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts new file mode 100644 index 0000000000..c7caa89f08 --- /dev/null +++ b/packages/bash/bash-env/src/index.ts @@ -0,0 +1,217 @@ +/** + * Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of + * trusted, per-execution `DSH_*` variables consumed by the model-facing shell + * tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by + * the registry itself while plugins can register additional, enumerable facts + * with effect-scoped disposal. + * + * @module @deepseek-ai/dsh-bash-env + */ + +import { Service, type Context } from 'cordis' +import z from 'schemastery' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-persistence' + +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + +export const name = 'bash-env' +export const inject: string[] = [] + +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} + +/** Runtime configuration schema for the bash-env plugin. */ +export const Config: z = z.object({ + dshHome: z.string(), +}) + +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model shell call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the shell tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model shell call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map() + private readonly keyOwners = new Map() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one shell tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + +/** + * Load the bash-env plugin: register the `ctx.bashEnv` service and the + * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`). + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ +export function apply(ctx: Context, config: Config = {}): void { + const registry = new BashEnvRegistry(ctx, config) + registry.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) +} diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts new file mode 100644 index 0000000000..31f842c56d --- /dev/null +++ b/packages/bash/bash-env/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`. + * @module @deepseek-ai/dsh-bash-env/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' + +/** Cordis companion plugin name. */ +export const name = 'bash-env-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * registration/collection; it publishes no independent snapshot that a companion could cross-check. + */ +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 */ diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts new file mode 100644 index 0000000000..c93a768f80 --- /dev/null +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -0,0 +1,237 @@ +/** + * Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor + * ownership and validation, collection ordering, effect-scoped disposal, and + * the explicit disposer contract. + */ + +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' + +const testToolSignal = new AbortController().signal + +afterEach(() => vi.unstubAllEnvs()) + +function execution(sessionId?: string): ToolExecution { + return { + signal: testToolSignal, + token: Symbol('bash-env-test') as ToolExecution['token'], + callId: CallId('bash-env-call'), + name: 'bash', + arguments: { command: 'true' }, + ...(sessionId === undefined + ? {} + : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }), + } +} + +describe('BashEnvRegistry', () => { + it('collects unconditional shell facts and the current agent session id', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + + expect(registry.collect(execution())).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SHELL: '1', + }) + expect(registry.collect(execution('session-a'))).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SESSION_ID: 'session-a', + DSH_SHELL: '1', + }) + }) + + it('resolves DSH_HOME from the ambient override or the user-home default', () => { + vi.stubEnv('DSH_HOME', './ambient-dsh-home') + const fromEnvironment = new BashEnvRegistry(new Context()) + expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home')) + + vi.stubEnv('DSH_HOME', undefined) + const fromDefault = new BashEnvRegistry(new Context()) + expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh')) + }) + + it('collects declared contributor variables and omits unavailable values', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'optional-session-fact', + variables: { + DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' }, + }, + resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id }, + }) + registry.register({ + name: 'always-available-fact', + variables: { + DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' }, + }, + resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }), + }) + + expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL') + expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes') + expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b') + expect(registry.list()).toEqual([ + { + contributor: 'always-available-fact', + description: 'Always-available test fact.', + key: 'DSH_ALWAYS_AVAILABLE', + }, + { + contributor: 'optional-session-fact', + description: 'Optional session-scoped test fact.', + key: 'DSH_SESSION_OPTIONAL', + }, + ]) + }) + + it('rejects duplicate variable ownership at registration time', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'first', + variables: { DSH_SHARED: { description: 'First owner.' } }, + resolve: () => ({ DSH_SHARED: 'first' }), + }) + + expect(() => registry.register({ + name: 'second', + variables: { DSH_SHARED: { description: 'Second owner.' } }, + resolve: () => ({ DSH_SHARED: 'second' }), + })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/) + }) + + it('rejects duplicate contributor names and malformed declarations', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'declared', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({}), + }) + + expect(() => registry.register({ + name: 'declared', + variables: { DSH_ANOTHER: { description: 'Another fact.' } }, + resolve: () => ({}), + })).toThrow(/already registered/) + expect(() => registry.register({ + name: ' ', + variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } }, + resolve: () => ({}), + })).toThrow(/name must be non-empty/) + expect(() => registry.register({ + name: 'invalid-key', + variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>, + resolve: () => ({}), + })).toThrow(/invalid key/) + expect(() => registry.register({ + name: 'reserved-key', + variables: { DSH_HOME: { description: 'Reserved key.' } }, + resolve: () => ({}), + })).toThrow(/reserved key/) + expect(() => registry.register({ + name: 'blank-description', + variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } }, + resolve: () => ({}), + })).toThrow(/must describe/) + }) + + it('rejects undeclared variables returned by a contributor', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'drifted-provider', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({ DSH_UNDECLARED: 'bad' }), + }) + + expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) + }) + + it('rejects non-string values returned by a contributor', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'wrong-value-type', + variables: { DSH_STRING: { description: 'String fact.' } }, + resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>, + }) + + expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) + }) + + it('removes an effect-scoped contributor when its plugin is disposed', async () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + const fiber = await ctx.plugin({ + inject: ['bashEnv'], + apply(inner: Context) { + inner.bashEnv.register({ + name: 'temporary', + variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } }, + resolve: () => ({ DSH_TEMPORARY: 'present' }), + }) + }, + }) + + expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present') + await fiber.dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY') + }) + + it('returns an explicit contributor disposer', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + const dispose = registry.register({ + name: 'explicit-disposal', + variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } }, + resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }), + }) + + expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present') + dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') + }) + + it('the plugin registers the service and the persistence contributor on load', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry) + expect(ctx.bashEnv.list()).toEqual([ + { + contributor: 'session-persistence', + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + key: 'DSH_SESSION_JSONL', + }, + ]) + }) + + it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl') + }) + + it('the persistence contributor omits the variable for a non-jsonl backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + ctx.provide('sessionPersistence', { + locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }), + }) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) + + it('the persistence contributor omits the variable without a persistence backend', async () => { + const ctx = new Context() + await ctx.plugin(BashEnvPlugin) + expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL') + }) +}) diff --git a/packages/bash/bash-env/tsconfig.json b/packages/bash/bash-env/tsconfig.json new file mode 100644 index 0000000000..bcf5eb5229 --- /dev/null +++ b/packages/bash/bash-env/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..e7dc0187ea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -184,7 +184,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'collect(execution: ToolExecution): DshEnvironment', - jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', + jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one shell tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', }, { signature: 'list(): BashEnvVariableInfo[]', diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5af9f4fc8c..65c79bda58 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ed723c26f5..041972cb9f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,6 +42,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, + 'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 44fc039f8a..a21de0d323 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"], "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"], + "@deepseek-ai/dsh-bash-env": ["./packages/bash/bash-env/src/index.ts"], "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"], "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index da821faa10..70869ede22 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -138,6 +138,7 @@ { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, diff --git a/vitest.config.ts b/vitest.config.ts index 0fa2a6dd53..14d5be103f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,10 +14,11 @@ const windowsUnsupportedPackages = process.platform === 'win32' // Bash-requiring suites (a real POSIX shell is unavailable on Windows). // The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay // INCLUDED: PowerShell ships with Windows, so they run natively here. + // Replacing the old 'packages/bash/*' glob with this explicit list also + // newly INCLUDES packages/bash/bash (the pure seam package) on Windows. 'packages/bash/bash-local', 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', - 'packages/bash/tool-bash-persistent', 'packages/hooks/*', 'packages/subprocess/*', 'packages/pty/pty-local', From 87db82e8219213256f104d39bbc90a43d0c74557 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:01 +0800 Subject: [PATCH 015/108] refactor(tool-bash): consume ctx.bashEnv from the shared bash-env package --- packages/bash/tool-bash/package.json | 6 +- packages/bash/tool-bash/src/index.ts | 196 +----------------- .../bash/tool-bash/tests/bash-env.spec.ts | 193 ----------------- .../bash/tool-bash/tests/integration.spec.ts | 4 +- packages/bash/tool-bash/tests/tools.spec.ts | 10 +- packages/bash/tool-bash/tsconfig.json | 9 +- 6 files changed, 23 insertions(+), 395 deletions(-) delete mode 100644 packages/bash/tool-bash/tests/bash-env.spec.ts diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index c34e1c6e7b..c9734b46eb 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -29,12 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@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-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^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", @@ -49,15 +48,14 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 3b3874bc59..c2a5c3e288 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,205 +8,39 @@ * @module @deepseek-ai/dsh-tool-bash */ -import { Service, type Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } 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-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-bash-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' -declare module 'cordis' { - interface Context { - bashEnv: BashEnvRegistry - } -} - export const name = 'tool-bash' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - dshHome: z.string(), }) -/** Model-visible metadata for one managed `DSH_*` environment variable. */ -export interface BashEnvVariable { - /** Concise description of the environment fact represented by the variable. */ - description: string -} - -/** - * A plugin contribution to the managed environment of each model bash call. - * Declared keys make ownership conflicts detectable before the first command; - * `resolve` computes only the values available for the current execution. - */ -export interface BashEnvContributor { - /** Stable contributor name used in diagnostics and duplicate detection. */ - name: string - /** Complete set of `DSH_*` keys this contributor may return. */ - variables: Readonly> - /** - * Resolve this contributor's available values for one tool execution. - * @param execution - the bash tool execution and its optional calling agent. - * @returns a partial map containing only keys declared in {@link variables}. - */ - resolve(execution: ToolExecution): Readonly>> -} - -/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ -export interface BashEnvVariableInfo extends BashEnvVariable { - /** Contributor that owns the variable. */ - contributor: string - /** Declared `DSH_*` environment variable name. */ - key: DshEnvironmentKey -} - -const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const -const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const -const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const -const RESERVED_BASH_ENV_KEYS = new Set([ - DSH_HOME_ENV, - DSH_SHELL_KEY, - DSH_SESSION_ID_KEY, -]) -const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ - -/** - * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. - * The namespace is rebuilt for every model bash call: ambient `DSH_*` values - * are discarded by the executor, then the registry's current snapshot is - * injected. Built-in shell facts remain owned by the registry itself while - * plugins can register additional, enumerable facts with effect-scoped - * disposal. - */ -export class BashEnvRegistry extends Service { - private readonly contributors = new Map() - private readonly keyOwners = new Map() - private readonly dshHome: string - - /** - * Create and install the `ctx.bashEnv` service. - * @param ctx - Cordis context that owns the service and registrations. - * @param config - home-directory configuration for the built-in variables. - */ - constructor(ctx: Context, config: Config = {}) { - super(ctx, 'bashEnv') - this.dshHome = resolveDshHome(config.dshHome) - } - - /** - * Register one environment contributor. Names and keys are unique; built-in - * keys are reserved. Registration is disposed with the calling plugin fiber. - * @param contributor - declared key ownership and per-execution resolver. - * @returns the disposer that unregisters the contribution. - */ - register(contributor: BashEnvContributor): () => void { - const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { - if (contributor.name.trim().length === 0) { - throw new Error('bash env contributor name must be non-empty') - } - if (this.contributors.has(contributor.name)) { - throw new Error(`bash env contributor "${contributor.name}" is already registered`) - } - - const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] - for (const [key, variable] of variables) { - if (!key.startsWith(DSH_ENV_PREFIX) - || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { - throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) - } - if (RESERVED_BASH_ENV_KEYS.has(key)) { - throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) - } - if (variable.description.trim().length === 0) { - throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) - } - const owner = this.keyOwners.get(key) - if (owner !== undefined) { - throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) - } - } - - this.contributors.set(contributor.name, contributor) - for (const [key] of variables) this.keyOwners.set(key, contributor.name) - yield () => { - this.contributors.delete(contributor.name) - for (const [key] of variables) this.keyOwners.delete(key) - } - }.bind(this), 'bashEnv.register()') - return () => void dispose() - } - - /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. - * @param execution - the current tool execution. - * @returns an immutable environment overlay containing built-ins and current contributions. - */ - collect(execution: ToolExecution): DshEnvironment { - const values: Record = { - [DSH_HOME_ENV]: this.dshHome, - [DSH_SHELL_KEY]: '1', - } - if (execution.agent !== undefined) { - values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id - } - - for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { - const resolved = contributor.resolve(execution) - for (const [rawKey, value] of Object.entries(resolved)) { - const key = rawKey as DshEnvironmentKey - if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) - } - if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) - } - values[key] = value - } - } - - return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) - } - - // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, - // prompt, or UI code treats list() as an exhaustive environment catalog. - /** - * Enumerate plugin-contributed variables without executing their resolvers. - * @returns declarations sorted by environment variable name. - */ - list(): BashEnvVariableInfo[] { - return [...this.contributors.values()] - .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ - contributor: contributor.name, - description: variable.description, - key: key as DshEnvironmentKey, - }))) - .sort((left, right) => left.key.localeCompare(right.key)) - } -} - /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string @@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { - // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell - // environment plugin; replacing this tool with persistent Bash must not - // remove the managed DSH_* contributor seam. - const bashEnv = new BashEnvRegistry(ctx, config) - bashEnv.register({ - name: 'session-persistence', - variables: { - [DSH_SESSION_JSONL_KEY]: { - description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', - }, - }, - resolve(execution) { - const agent = execution.agent - if (agent === undefined) return {} - const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} - }, - }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void { ? standingPolicy : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot) - const dshEnv = bashEnv.collect(exec) + const dshEnv = ctx.bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts deleted file mode 100644 index d988075c5b..0000000000 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { homedir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { CallId } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' - -const testToolSignal = new AbortController().signal - -afterEach(() => vi.unstubAllEnvs()) - -function execution(sessionId?: string): ToolExecution { - return { - signal: testToolSignal, - token: Symbol('bash-env-test') as ToolExecution['token'], - callId: CallId('bash-env-call'), - name: 'bash', - arguments: { command: 'true' }, - ...(sessionId === undefined - ? {} - : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }), - } -} - -describe('BashEnvRegistry', () => { - it('collects unconditional shell facts and the current agent session id', () => { - const ctx = new Context() - const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - - expect(registry.collect(execution())).toEqual({ - DSH_HOME: resolve('./test-dsh-home'), - DSH_SHELL: '1', - }) - expect(registry.collect(execution('session-a'))).toEqual({ - DSH_HOME: resolve('./test-dsh-home'), - DSH_SESSION_ID: 'session-a', - DSH_SHELL: '1', - }) - }) - - it('resolves DSH_HOME from the ambient override or the user-home default', () => { - vi.stubEnv('DSH_HOME', './ambient-dsh-home') - const fromEnvironment = new BashEnvRegistry(new Context()) - expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home')) - - vi.stubEnv('DSH_HOME', undefined) - const fromDefault = new BashEnvRegistry(new Context()) - expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh')) - }) - - it('collects declared contributor variables and omits unavailable values', () => { - const ctx = new Context() - const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - registry.register({ - name: 'optional-session-fact', - variables: { - DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' }, - }, - resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id }, - }) - registry.register({ - name: 'always-available-fact', - variables: { - DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' }, - }, - resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }), - }) - - expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL') - expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes') - expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b') - expect(registry.list()).toEqual([ - { - contributor: 'always-available-fact', - description: 'Always-available test fact.', - key: 'DSH_ALWAYS_AVAILABLE', - }, - { - contributor: 'optional-session-fact', - description: 'Optional session-scoped test fact.', - key: 'DSH_SESSION_OPTIONAL', - }, - ]) - }) - - it('rejects duplicate variable ownership at registration time', () => { - const ctx = new Context() - const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - registry.register({ - name: 'first', - variables: { DSH_SHARED: { description: 'First owner.' } }, - resolve: () => ({ DSH_SHARED: 'first' }), - }) - - expect(() => registry.register({ - name: 'second', - variables: { DSH_SHARED: { description: 'Second owner.' } }, - resolve: () => ({ DSH_SHARED: 'second' }), - })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/) - }) - - it('rejects duplicate contributor names and malformed declarations', () => { - const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) - registry.register({ - name: 'declared', - variables: { DSH_DECLARED: { description: 'Declared fact.' } }, - resolve: () => ({}), - }) - - expect(() => registry.register({ - name: 'declared', - variables: { DSH_ANOTHER: { description: 'Another fact.' } }, - resolve: () => ({}), - })).toThrow(/already registered/) - expect(() => registry.register({ - name: ' ', - variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } }, - resolve: () => ({}), - })).toThrow(/name must be non-empty/) - expect(() => registry.register({ - name: 'invalid-key', - variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>, - resolve: () => ({}), - })).toThrow(/invalid key/) - expect(() => registry.register({ - name: 'reserved-key', - variables: { DSH_HOME: { description: 'Reserved key.' } }, - resolve: () => ({}), - })).toThrow(/reserved key/) - expect(() => registry.register({ - name: 'blank-description', - variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } }, - resolve: () => ({}), - })).toThrow(/must describe/) - }) - - it('rejects undeclared variables returned by a contributor', () => { - const ctx = new Context() - const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - registry.register({ - name: 'drifted-provider', - variables: { DSH_DECLARED: { description: 'Declared fact.' } }, - resolve: () => ({ DSH_UNDECLARED: 'bad' }), - }) - - expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) - }) - - it('rejects non-string values returned by a contributor', () => { - const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) - registry.register({ - name: 'wrong-value-type', - variables: { DSH_STRING: { description: 'String fact.' } }, - resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>, - }) - - expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) - }) - - it('removes an effect-scoped contributor when its plugin is disposed', async () => { - const ctx = new Context() - const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) - const fiber = await ctx.plugin({ - inject: ['bashEnv'], - apply(inner: Context) { - inner.bashEnv.register({ - name: 'temporary', - variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } }, - resolve: () => ({ DSH_TEMPORARY: 'present' }), - }) - }, - }) - - expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present') - await fiber.dispose() - expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY') - }) - - it('returns an explicit contributor disposer', () => { - const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) - const dispose = registry.register({ - name: 'explicit-disposal', - variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } }, - resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }), - }) - - expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present') - dispose() - expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') - }) -}) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 5616afcdef..b76f24bc04 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(ToolBash) ctx.llm.registerAdapter(['mock'], adapter) return ctx } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index a52b3f16f1..22fe3bc4b6 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -281,6 +282,7 @@ describe('bash tool', () => { await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -403,6 +405,7 @@ describe('bash tool', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) await ctx.plugin(LocalSubprocessService) @@ -493,6 +496,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const controller = new AbortController() @@ -520,6 +524,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(CountingStartExecutor) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) @@ -534,6 +539,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) @@ -568,6 +574,7 @@ describe('sandbox escalation through the generic task producer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(RecordingSandboxExecutor) + await ctx.plugin(BashEnvPlugin) await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') }) @@ -1097,8 +1104,9 @@ describe('the model-facing bash tool builds its request from named args only (no } await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome }) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) + await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingBashExecutor } } diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 00e9195f9f..b122ed58ca 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,21 +26,18 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, - { - "path": "../../util/paths" - }, { "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../ui/user-approval" }, From af9af8ca05093f99ad48446ab245b25b92870c18 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:15 +0800 Subject: [PATCH 016/108] fix(pwsh-local): pin UTF-8 I/O so the Windows PowerShell 5.1 fallback cannot garble output --- packages/bash/pwsh-local/src/index.ts | 18 +++++++- .../bash/pwsh-local/tests/executor.spec.ts | 44 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 8548731a28..b93bbb8d89 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -34,6 +34,17 @@ export const ENV_OVERRIDES = { GIT_PAGER: 'cat', } as const +/** + * UTF-8 I/O pinning prepended to every command. The subprocess collector + * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort + * executable fallback) writes the console/OEM code page by default, which + * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The + * statements ride on line 1 after `; ` separators so PowerShell error line + * numbers stay accurate. + */ +export const ENCODING_PREAMBLE = + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + /** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ const DEFAULT_GRACE_MS = 3_000 @@ -197,7 +208,7 @@ export class PwshLocalExecutor extends BashExecutor { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command], + argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -307,7 +318,10 @@ export class PwshLocalExecutor extends BashExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. - * The base implementation is intentionally empty. + * 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. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. */ diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 1794666911..5113f6a988 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -15,13 +15,17 @@ import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { PwshLocalExecutor, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import SubprocessService from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) -const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 /** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ const lf = (text: string): string => text.replace(/\r\n/g, '\n') @@ -110,6 +114,42 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => }) }) +describe('spawn construction (pure, every platform)', () => { + /** A subprocess service that records spawn specs and settles instantly. */ + class CapturingSubprocessService extends SubprocessService { + specs: SubprocessSpawnSpec[] = [] + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.specs.push(spec) + return { + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: () => {}, + waitForExit: async () => true, + } + } + } + + it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessService(ctx) + await ctx.plugin(PwshLocalExecutor) + await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' })) + expect(subprocess.specs).toHaveLength(1) + const { argv } = subprocess.specs[0]! + expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command']) + expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`) + expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') + expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') + }) +}) + describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { it('resolves with output and the effective timeout', async () => { const { bash } = await setup({ timeoutMs: 5_000 }) From 33810ae7747570d3764c9092dcc74e16fbb3212e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:29 +0800 Subject: [PATCH 017/108] feat(tool-pwsh): mirror dsh-tool-bash call-for-call minus the sandbox surface --- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 46 +- packages/bash/tool-pwsh/README.zh.md | 122 +++--- packages/bash/tool-pwsh/package.json | 11 +- packages/bash/tool-pwsh/src/background.ts | 31 ++ packages/bash/tool-pwsh/src/index.ts | 220 +++++----- packages/bash/tool-pwsh/src/render.ts | 81 ++++ .../bash/tool-pwsh/tests/integration.spec.ts | 53 ++- packages/bash/tool-pwsh/tests/loader.spec.ts | 63 +++ packages/bash/tool-pwsh/tests/tools.spec.ts | 401 ++++++++++++++++-- packages/bash/tool-pwsh/tsconfig.json | 8 +- 11 files changed, 821 insertions(+), 219 deletions(-) create mode 100644 packages/bash/tool-pwsh/src/background.ts create mode 100644 packages/bash/tool-pwsh/src/render.ts create mode 100644 packages/bash/tool-pwsh/tests/loader.spec.ts diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index e16107e42f..102e50d0ce 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -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: 4f1d62dbf49fef678e3285776c466286535d66da -README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c +README.md: b5acc73a68d3b309860554d4c1e8d979eb8d1eec +README.zh.md: 4d678c42194b78da8b4b10e01f8b9e666d6236d8 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 4f1d62dbf4..b5acc73a68 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -2,13 +2,13 @@ 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. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin. +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). -Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). -The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests. +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. -The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. +The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. ## Tools @@ -20,20 +20,23 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): check th | `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | | `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. | `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()`. ### Managed shell environment -Every call receives a freshly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. +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 timeout, signal, and exit-code markers: `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. +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`. -The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text. +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 ` 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. ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. ## Model Experience @@ -46,7 +49,7 @@ Every request in this plugin's registration scope contains the pwsh guidance bel ##### Pwsh guidance ```markdown -Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. ``` #### Token effect @@ -75,7 +78,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 `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. #### Token effect @@ -85,11 +88,25 @@ Zero result tokens before a call. Output is bounded per stream, while each emitt Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Background result + +#### What the model sees + +A background start renders exactly `started background task `; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes. + +#### Token effect + +The ack is a fixed short line; task output is bounded per read. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. 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 `, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. 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 `, `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 @@ -101,7 +118,8 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin. -- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin. +- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **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. -- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet. +- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work. +- **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. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index bbeece3c64..4d678c4219 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -2,106 +2,124 @@ [English](README.md) | 中文 -面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。 +注册在 `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` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 -包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。 +包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 -该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。 +插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 ## 工具 ### `pwsh` -| 参数 | 类型 | 说明 | +| Arg | Type | Notes | |---|---|---| -| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | -| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | -| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 | -| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 | -`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。 +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 -### 受管 shell 环境 +### Managed shell environment -每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。 +每次前台与后台模型 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]` 分段,以及适用的超时、信号与退出码标记:`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。 +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、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: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 -## UI 呈现 +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 -工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。 +## UI presentation -## 模型体验 +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡;完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 -### 系统提示词 +## Model Experience -#### 模型看到的内容 +### System prompt -该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。 +#### What the model sees -##### Pwsh 指导 +本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Pwsh guidance ```markdown -Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. ``` -#### Token 影响 +#### Token effect -插件激活期间每个请求有少量固定输入成本。 +插件激活期间每次请求的固定小额输入成本。 -#### KV Cache 影响 +#### KV Cache effect -注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。 +注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。 -### 工具 schema +### Tool schemas -#### 模型看到的内容 +#### What the model sees -模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。 +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 -#### Token 影响 +#### Token effect -工具可见时每个请求有固定的 schema 成本。 +工具可见的每个请求上的固定 schema 成本。 -#### KV Cache 影响 +#### KV Cache effect -可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。 +可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。 -### 前台结果 +### Foreground result -#### 模型看到的内容 +#### What the model sees -渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`。 +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 -#### Token 影响 +#### Token effect -调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。 +调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。 -#### KV Cache 影响 +#### KV Cache effect -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 -### 工具错误 +### Background result -#### 模型看到的内容 +#### What the model sees -校验与基础设施失败被规范化为 `Error: `。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got ` 与 `tool call aborted`。 +后台启动精确渲染为 `started background task `;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。 -#### Token 影响 +#### Token effect -只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。 +ack 是固定短行;任务输出按读取有界。 -#### KV Cache 影响 +#### KV Cache effect -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 -## 已知局限与延期工作 +### Tool errors -- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。 -- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。 -- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。 +#### What the model sees + +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`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 + +只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +## Known Limitations and Deferred Work + +- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 +- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 90e12438d8..7f43cd57e8 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -29,11 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@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-paths": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^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", "cordis": "^4.0.0-rc.7" }, @@ -43,13 +43,16 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "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:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/tool-pwsh/src/background.ts b/packages/bash/tool-pwsh/src/background.ts new file mode 100644 index 0000000000..5e3464f76b --- /dev/null +++ b/packages/bash/tool-pwsh/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-task adaptation for background pwsh process handles — the shell-agnostic + * twin of `dsh-tool-bash`'s background adaptation. + * + * @module @deepseek-ai/dsh-tool-pwsh/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +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 + // 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') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 704c35647c..c8a02f0428 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,38 +4,48 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Minimal by design: no background tasks, no sandbox escalation — this is the - * "works on my Windows machine" profile until the full bash-tool feature set - * gets a PowerShell twin. + * 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 stays on the existing generic/terminal + * cards; a pwsh-specific rendering twin is roadmap work. * * @module @deepseek-ai/dsh-tool-pwsh */ import { isAbsolute, resolve as resolvePath } from 'node:path' -import { Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { TerminalCallView, 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-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type {} from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-bash-env' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { renderPwshProcessRead, renderPwshResult } from './render.ts' + +declare module '@deepseek-ai/dsh-tasks' { + interface TaskKindMap { + pwsh: 'pwsh' + } +} export const name = 'tool-pwsh' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Plugin config (currently empty; kept as a schema so deployments can grow it). */ +/** Configuration for the pwsh tool. */ export interface Config { - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ - dshHome: z.string(), + enableRunInBackground: z.boolean().default(true), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -44,6 +54,7 @@ interface PwshToolArgs { description: string timeoutMs?: number workdir?: string + run_in_background?: boolean } /** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ @@ -72,12 +83,18 @@ function validatePwshArgs(args: PwshToolArgs): void { } /* jscpd:ignore-end */ -function pwshDescription(): string { +function pwshDescription(backgroundEnabled: boolean): 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. ' + '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]`. ' - + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.' + + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + '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 } /** @@ -93,32 +110,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } -/** - * The model-facing text of one foreground pwsh result: stdout, a marked - * stderr section, then the applicable timeout, signal, and exit markers — - * each separated by a newline only when the accumulated text lacks one, so a - * trailing newline in stdout never produces a blank line. - * - * @param value - the canonical foreground result (the schema-derived value shape). - * @returns the model-facing text. - */ -function renderPwshOutput(value: RenderablePwshOutput): string { - let rendered = value.stdout.text - const marker = (line: string): void => { - rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line - } - if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`) - if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`) - if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`) - if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`) - return rendered -} - -/** - * Detach the executor DTO from readonly seam interfaces into plain JSON data. - * @param result - the executor's run outcome. - * @returns the canonical foreground result the tool returns and renders. - */ +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { const output = (stream: BashRunResult['stdout']) => ({ text: stream.text, @@ -132,48 +124,32 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { timedOut: result.timedOut, aborted: result.aborted, timeoutMs: result.timeoutMs, + /* 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), } } -/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */ -interface RenderablePwshOutput { - exitCode: number | null - signal: string | null - timedOut: boolean - timeoutMs: number - stdout: { text: string } - stderr: { text: string } -} - -/** - * The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell - * marker, and the session identity when an agent is present. - */ -function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment { - const values: Record = { - [DSH_HOME_ENV]: dshHome, - [`${DSH_ENV_PREFIX}SHELL`]: '1', - } - if (exec.agent !== undefined) { - values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id - } - return values -} +/** Canonical background-handle properties shared by the pwsh output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const +/* jscpd:ignore-end */ export function apply(ctx: Context, config: Config = {}): void { - const dshHome = resolveDshHome(config.dshHome) + const backgroundEnabled = config.enableRunInBackground ?? true ctx.systemPrompt.section({ name: 'tool:pwsh', order: 105, - text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.', + text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', }) ctx.tools.register(defineTool({ name: 'pwsh', - description: pwshDescription(), + description: pwshDescription(backgroundEnabled), parameters: { command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, description: { @@ -185,59 +161,111 @@ export function apply(ctx: Context, config: Config = {}): void { }, timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, + ...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.' }, + } : {}, }, 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 // Agent Note). - /* jscpd:ignore-start -- deliberate foreground-result schema symmetry with dsh-tool-bash. */ + /* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */ schema: { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'foreground' }, - exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, - signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, - timedOut: { type: 'boolean', required: true }, - aborted: { type: 'boolean', required: true }, - timeoutMs: { type: 'number', required: true }, - stdout: { + oneOf: [ + { type: 'object', additionalProperties: false, - required: true, - properties: { - text: { type: 'string', required: true }, - truncated: { type: 'boolean', required: true }, - spillPath: { type: 'string' }, - }, + properties: BACKGROUND_OUTPUT_PROPERTIES, }, - stderr: { + { type: 'object', additionalProperties: false, - required: true, properties: { - text: { type: 'string', required: true }, - truncated: { type: 'boolean', required: true }, - spillPath: { type: 'string' }, + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, }, }, - }, + ], }, /* jscpd:ignore-end */ render: (_args, value) => [{ type: 'text', - text: renderPwshOutput(value), + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderPwshResult(value), }], }, - /* jscpd:ignore-start -- the foreground execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ + /* 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) const workdir = resolveWorkdir(args.workdir, exec) - const result = await ctx.bash.run(ctx.bash.resolve({ + const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, - dshEnv: collectDshEnv(exec, dshHome), + dshEnv: ctx.bashEnv.collect(exec), + } + if (args.run_in_background === true) { + // Undeclared keys are allowed, so schema omission also needs enforcement. + if (!backgroundEnabled) { + throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + } + const tasks = ctx.get('tasks') + if (tasks === undefined) { + 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', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + run: () => { + const proc = ctx.bash.start(ctx.bash.resolve(request)) + return { + cancel: () => void proc.kill(), + done: proc.done.then(() => processOutcome(proc)), + readOutput: () => renderPwshProcessRead(proc.readOutput()), + } + }, + }) + return { kind: 'background' as const, taskId: id } + } + const result = await ctx.bash.run(ctx.bash.resolve({ + ...request, signal: exec.signal, })) if (result.aborted) { diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts new file mode 100644 index 0000000000..42f4bc696c --- /dev/null +++ b/packages/bash/tool-pwsh/src/render.ts @@ -0,0 +1,81 @@ +/** + * 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. + * + * @module @deepseek-ai/dsh-tool-pwsh/render + */ + +import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** The renderable foreground result shape (the schema-derived value, no `kind`). */ +export interface RenderablePwshResult { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * 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. + * @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 { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // 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) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * 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. + */ +export function renderPwshProcessRead(read: BashProcessRead): 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 (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts index c703c6aa0e..711f7663b1 100644 --- a/packages/bash/tool-pwsh/tests/integration.spec.ts +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -2,10 +2,11 @@ * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell * process. These verify the world — actual commands run, stdout/stderr come - * back, exit codes render, timeouts abort, and per-session cwd resolution - * works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation - * for hosts without PowerShell); the fake-executor suite (tools.spec.ts) - * carries the coverage gate. + * back, exit codes render, timeouts abort, background tasks settle through the + * generic task runtime, and per-session cwd resolution works. The suite + * self-skips when no `pwsh` is on PATH (a CI accommodation for hosts without + * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage + * gate. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -17,13 +18,18 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' const testToolSignal = new AbortController().signal -const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 /** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ const lf = (text: string): string => text.replace(/\r\n/g, '\n') @@ -54,7 +60,10 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) await ctx.plugin(ToolPwsh) }) @@ -65,12 +74,12 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) - it('runs a command and returns stdout with the exit marker', async () => { + it('runs a command and returns stdout with no marker on a clean exit', async () => { const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected pwsh success') expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) - expect(lf(text(result))).toBe('hi\n[exit code: 0]') + expect(lf(text(result))).toBe('hi\n') }) it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { @@ -88,7 +97,7 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { description: 'read greeting', }, agent()) expect(result.isError).toBe(false) - expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]') + expect(lf(text(result))).toBe('hello pwsh\n') }) it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { @@ -116,4 +125,30 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) }) + + it('a background run settles through the REAL task_output tool', async () => { + const started = await call('pwsh', { + command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done', + description: 'background greeting', + run_in_background: true, + }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toMatchObject({ kind: 'background' }) + const taskId = (started.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so collect incrementally — + // the same two-step shape as the bash background suite. + const deadline = Date.now() + 10_000 + let output = '' + while (Date.now() < deadline) { + const read = await call('task_output', { task_id: taskId }) + output += text(read) + if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(output).toContain('bg-done') + expect(output).toContain('[status: completed, exit code: 0]') + }) }) diff --git a/packages/bash/tool-pwsh/tests/loader.spec.ts b/packages/bash/tool-pwsh/tests/loader.spec.ts new file mode 100644 index 0000000000..7037162579 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/loader.spec.ts @@ -0,0 +1,63 @@ +/** + * REAL-composition tier (packages/AGENTS.md): boot the examples-owned + * tool-pwsh Loader fixture as a subprocess through the same app/boot path a + * deployment uses, execute real foreground and background pwsh commands + * through the tool registry, and assert the assembled model-visible surface: + * schema, prompt section, and rendered results. Self-skips when no `pwsh` + * executable exists (a CI accommodation for hosts without PowerShell). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +interface PwshLoaderReport { + schemaHasRunInBackground: boolean + promptHasMarkerSection: boolean + foregroundText: string + backgroundText: string +} + +describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => { + it('registers the pwsh surface and renders real foreground and background results', async () => { + let report: PwshLoaderReport | undefined + const { stderr } = await runLoaderSmoke({ + label: 'tool-pwsh loader smoke', + tempDirPrefix: 'tool-pwsh-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(report).toBeDefined() + expect(report).toMatchObject({ + schemaHasRunInBackground: true, + promptHasMarkerSection: true, + }) + expect(report?.foregroundText).toBe('loader-ok\n') + expect(report?.backgroundText).toContain('loader-bg-ok') + expect(report?.backgroundText).toContain('[status: completed, exit code: 0]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 5361130991..9d1eedba08 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -2,10 +2,11 @@ * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, * exercised through `ctx.tools.execute()` so nothing bypasses the tool * registry. The fake executor makes every seam outcome scriptable — output - * text, truncation, timeout, abort, nonzero exits — so these tests verify the - * schema, argument validation, workdir derivation, managed `DSH_*` collection, - * abort translation, canonical result projection, rendering, and the UI - * presenters. Real-pwsh behavior is pinned separately in integration.spec.ts. + * 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 + * is pinned separately in integration.spec.ts. */ import { describe, expect, it } from 'vitest' @@ -15,23 +16,33 @@ import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +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 { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' 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' +import { processOutcome } from '../src/background.ts' +import { renderPwshProcessRead } from '../src/render.ts' const testToolSignal = new AbortController().signal /** * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` - * returns the armed script, `start()` throws — the pwsh tool must NEVER create - * a background task. + * returns the armed foreground script, `start()` returns the armed background + * handle. */ class FakeBash extends BashExecutor { requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n') override resolve(request: BashExecRequest): BashExecSpec { this.requests.push(request) @@ -53,9 +64,10 @@ class FakeBash extends BashExecutor { return this.handler(spec) } - override start(): BashProcess { + override start(spec: BashExecSpec): BashProcess { this.startCalls++ - throw new Error('the pwsh tool must never start a background task') + this.specs.push(spec) + return this.backgroundHandler(spec) } } @@ -73,33 +85,95 @@ function runResult(stdout: string, overrides?: Partial): BashRunR } } -async function setup(config: Partial = {}) { +/** A settled successful background handle; overrides script failure shapes. */ +function fakeProcess(delta = 'bg-ok\n'): BashProcess { + let consumed = false + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => { + if (consumed) return { delta: '', lossy: false } + consumed = true + return { delta, lossy: false } + }, + kill: () => false, + } +} + +/** A running background handle whose kill() settles it as killed (like a real task_kill). */ +function killableProcess(): BashProcess { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + if (proc.status !== 'running') return false + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + } + return proc +} + +async function setup(toolConfig: Partial = {}, dshHome?: string) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(FakeBash) - await ctx.plugin(ToolPwsh, config) + await ctx.plugin(ToolPwsh, toolConfig) const bash = ctx.bash as FakeBash return { ctx, bash } } -/** A stand-in agent whose session header carries the given cwd and id. */ -const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } }) +/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */ +async function setupWithTasks(toolConfig: Partial = {}, dshHome?: string) { + 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, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** + * 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`. + */ +function registerFakeAgent(ctx: Context, sessionId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) + const agent = { + id, + ctx: scopeFiber.ctx, + session: { id, header: { version: 0, id, createdAt: 0 } }, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} let callCounter = 0 -function call( - ctx: Context, - name: string, - args: unknown, - options: { agent?: object; signal?: AbortSignal } = {}, -) { +function call(ctx: Context, name: string, args: unknown, agent?: Agent) { return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, - ...options.agent ? { agent: options.agent as never } : {}, - ...options.signal ? { signal: options.signal } : {}, + ...agent ? { agent } : {}, }) } @@ -107,6 +181,23 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +async function callUntilText( + ctx: Context, + name: string, + args: unknown, + expected: string, + timeoutMs = 5_000, +): Promise>> { + const deadline = Date.now() + timeoutMs + let last: Awaited> | undefined + while (Date.now() < deadline) { + last = await call(ctx, name, args) + if (text(last).includes(expected)) return last + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`) +} + describe('registration', () => { it('registers the pwsh tool with its prompt section and schema', async () => { const { ctx } = await setup() @@ -118,10 +209,12 @@ describe('registration', () => { description: { type: 'string' }, timeoutMs: { type: 'number' }, workdir: { type: 'string' }, + run_in_background: { type: 'boolean' }, }) expect(schema?.parameters.required).toEqual(['command', 'description']) const prompt = renderPrompt(await ctx.systemPrompt.assemble()) - expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result') + expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers') + expect(prompt).toContain('without a signal marker') }) it('stays pending until ctx.bash exists (inject)', async () => { @@ -136,6 +229,7 @@ describe('registration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) const fiber = await ctx.plugin(ToolPwsh) expect(ctx.tools.schemas()).toHaveLength(1) @@ -157,13 +251,15 @@ describe('argument validation', () => { describe('execution through the bash seam', () => { it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) - const { ctx, bash } = await setup({ dshHome }) + const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') + const agent = registerFakeAgent(ctx, 'session-1') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi', timeoutMs: 1234, - }, { agent: agent('/sessions/s1') }) + }, agent) expect(result.isError).toBe(false) const request = bash.requests[0] expect(request?.command).toBe('Write-Output hi') @@ -180,9 +276,11 @@ describe('execution through the bash seam', () => { it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') }) + const agent = registerFakeAgent(ctx, 'session-cwd') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) - await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent) expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) }) @@ -202,7 +300,12 @@ describe('execution through the bash seam', () => { const { ctx, bash } = await setup() const controller = new AbortController() bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal }) + await ctx.tools.execute({ + signal: controller.signal, + callId: CallId('call-signal'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'ok' }, + }) expect(bash.requests[0]?.signal).toBe(controller.signal) }) @@ -229,19 +332,60 @@ describe('execution through the bash seam', () => { expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') }) - it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => { + it('renders a clean exit without a marker and an empty body as (no output)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(text(clean)).toBe('hi\n') + + bash.handler = () => runResult('') + const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' }) + expect(text(empty)).toBe('(no output)') + }) + + it('renders stderr-only output without a stdout prefix', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]') + }) + + it('inserts the separating newline before the stderr section when stdout lacks one', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]') + }) + + it('renders the truncation notice with the spill path, then markers', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('tail', { stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, stderr: { text: '', truncated: false }, }) const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) - expect(text(result)).toBe('tail\n[exit code: 0]') + expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]') bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) // A timeout kill carries both facts, mirroring the bash tool's markers. - expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]') + expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('renders the truncation notice with (unavailable) when no spill path exists', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]') }) it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { @@ -251,15 +395,124 @@ describe('execution through the bash seam', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) }) +}) - it('never starts a background task', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) - bash.handler = () => runResult('', { exitCode: 1 }) - await call(ctx, 'pwsh', { command: 'missing', description: 'missing' }) +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() + const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' }) + expect(text(started)).toBe('started background task pwsh-1') + + const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok') + expect(text(read)).toContain('bg-ok') + // A later read reports the terminal outcome in the generic status line. + const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]') + expect(final.isError).toBe(false) + }) + + it('a running background task is killable through the REAL task_kill tool', async () => { + const { ctx, bash } = await setupWithTasks() + bash.backgroundHandler = () => killableProcess() + await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }) + expect(text(killed)).toBe('requested cancellation of task pwsh-1') + // The cancel reached the process handle; the task settles as killed with + // the signal detail mapped by processOutcome. + const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }) + expect(text(final)).toContain('[status: killed, signal: SIGTERM]') + }) + + it('a background task started by an agent is registered with that agent as owner', async () => { + const { ctx } = await setupWithTasks() + const agent = registerFakeAgent(ctx, 'sess-owner') + const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pwsh-1') + + const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' }) + expect(anon.isError).toBe(true) + expect(text(anon)).toMatch(/belongs to another session/) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent) + expect(killed.isError).toBe(false) + await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan + }) + + it('fails loud when the task runtime is not loaded', async () => { + const { ctx } = await setup() // no LocalTaskService / ToolTasks + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + }) + + it('a pre-aborted call is skipped before the process starts', async () => { + const { ctx, bash } = await setupWithTasks() + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId('call-pre-aborted'), + name: 'pwsh', + arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) expect(bash.startCalls).toBe(0) }) + + it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => { + // With no control surface, task preflight fails before the executor can spawn. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as FakeBash + + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no control surface is attached') + // Declare-then-execute: the failed preflight means no process ever ran. + expect(bash.startCalls).toBe(0) + }) + + it('enableRunInBackground: false removes the parameter and flips the description', async () => { + const { ctx } = await setup({ enableRunInBackground: false }) + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')! + expect(Object.keys(schema.parameters.properties as Record)) + .toEqual(['command', 'description', 'timeoutMs', 'workdir']) + expect(schema.description).toContain('Background execution is not available') + expect(schema.description).not.toContain('run_in_background') + + // Schema omission is advertising; execution must also enforce the opt-out. + const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) + expect(forced.isError).toBe(true) + expect(text(forced)).toContain('run_in_background is disabled for this deployment') + const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) + expect(foreground.isError).toBe(false) + }) + + it('applies the built-in background default when apply() receives a bare config', async () => { + // Bypasses the schemastery defaults on purpose: apply() must stand on its + // own `?? true` fallback when embedded programmatically without the schema. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + ToolPwsh.apply(ctx, {}) + const schema = ctx.tools.schemas()[0]! + expect(schema.parameters.properties).toHaveProperty('run_in_background') + expect(schema.description).toContain('task_output') + }) }) describe('UI presentation', () => { @@ -267,11 +520,11 @@ describe('UI presentation', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('hi\n') const args = { command: 'Write-Output hi', description: 'say hi' } - const result = await call(ctx, 'pwsh', args, { agent: agent('/w') }) + const result = await call(ctx, 'pwsh', args) const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) expect(view).toEqual({ card: 'generic', - content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }], + content: [{ type: 'text', text: '```console\nhi\n```' }], }) }) @@ -294,3 +547,75 @@ describe('UI presentation', () => { expect(definition?.presentResult?.(args, image as never)).toBeUndefined() }) }) + +describe('renderPwshProcessRead', () => { + const base: BashProcessRead = { delta: 'out\n', lossy: false } + + it('returns the delta verbatim for a lossless read', () => { + expect(renderPwshProcessRead(base)).toBe('out\n') + expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') + }) + + it('appends the loss notice with the available spill paths', () => { + expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') + expect(renderPwshProcessRead({ + ...base, + lossy: true, + stdoutSpillPath: 'C:\\spill\\out.log', + stderrSpillPath: 'C:\\spill\\err.log', + })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') + }) + + it('reports (unavailable) when a lossy read has no safe spill path', () => { + expect(renderPwshProcessRead({ ...base, lossy: true })) + .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') + }) + + it('an empty lossy delta is the notice alone', () => { + expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) + .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') + }) + + it('inserts the separating newline only when the delta lacks one', () => { + expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + }) +}) + +describe('processOutcome', () => { + function settled(over: Partial): BashProcess { + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...over, + } + } + + it('maps a signal-killed process to killed with the signal detail', () => { + expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' }))) + .toEqual({ status: 'killed', detail: 'signal: SIGTERM' }) + }) + + it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => { + expect(processOutcome(settled({ status: 'killed', exitCode: null }))) + .toEqual({ status: 'killed', detail: 'killed before exit' }) + }) + + it('maps a completed process to its exit code', () => { + expect(processOutcome(settled({ exitCode: 3 }))) + .toEqual({ status: 'completed', detail: 'exit code: 3' }) + }) + + it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => { + expect(processOutcome(settled({ exitCode: null }))) + .toEqual({ status: 'completed', detail: 'exit code: 0' }) + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 2811462193..61b2c69448 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -26,14 +26,14 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, { - "path": "../../util/paths" + "path": "../../bash/bash-env" + }, + { + "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt" From 96cf8a2fbc1fa439653fd6446786c24fe64e8010 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:42 +0800 Subject: [PATCH 018/108] chore(compositions): mount bash-env and the pwsh tool in shipped and demo compositions --- apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 3 + apps/cli/package.json | 1 + apps/cli/src/web.ts | 2 +- apps/cli/tsconfig.json | 3 + examples/acp-agent/tests/acp.snapshot.ts | 6 ++ .../tests/fixtures/bash/tool-pwsh/cordis.yml | 27 ++++++++ .../tests/fixtures/bash/tool-pwsh/driver.ts | 67 +++++++++++++++++++ .../acp-agent/tests/pwsh.cordis.snapshot.yml | 37 ++++++++++ examples/acp-agent/tests/pwsh.cordis.yml | 36 ++++++++++ examples/package.json | 3 + .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/README.zh.md | 2 +- .../examples/agent-spine-demo/package.json | 3 + .../examples/agent-spine-demo/src/index.ts | 4 +- .../examples/agent-spine-demo/tsconfig.json | 3 + 17 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts create mode 100644 examples/acp-agent/tests/pwsh.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/pwsh.cordis.yml diff --git a/apps/cli/composition.md b/apps/cli/composition.md index c4deb098c4..54755185ab 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -54,6 +54,8 @@ flowchart LR cfg --> plugin_tui_approval plugin_tui_permission["permission
@deepseek-ai/dsh-permission"] cfg --> plugin_tui_permission + plugin_tui_bash_env["bash-env
@deepseek-ai/dsh-bash-env"] + cfg --> plugin_tui_bash_env plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_tui_tool_bash plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] @@ -165,6 +167,7 @@ flowchart LR | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | +| `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index df7ed94258..a3d0dce332 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -174,6 +174,9 @@ sandbox: danger-full-access approval: never +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/package.json b/apps/cli/package.json index a6be4e9d90..710ba1f61c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,6 +23,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4fbfba4d8d..4af7a42013 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tool-bash' +import type {} from '@deepseek-ai/dsh-bash-env' import { AppCLIEntry } from './app-cli-entry.ts' // The shared core every `dsh` surface mounts, plus this surface's overlay over it. diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 2f995abf87..aafdf5ad9a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/bash-env" + }, { "path": "../../packages/bash/tool-bash" }, diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 326a7ee0c0..f191b5ce1b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -154,6 +154,12 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // The pwsh-tool-turn scenario is NOT registered yet: its overlay + // (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the bundle's bash tool + // for the PowerShell twin, so its header class needs its own prompt/tool + // sidecars and a recorded transcript. Both require a keyed environment + // (`test:snapshot:record`); the composition ships so the scenario can be + // registered and recorded in one keyed pass. { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml new file mode 100644 index 0000000000..c152b6ca67 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml @@ -0,0 +1,27 @@ +# Minimal tool-pwsh composition: real app boot path, real pwsh executor, real +# foreground + background tool calls; driven by the package's loader.spec.ts. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + config: + graceMs: 200 + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts new file mode 100644 index 0000000000..9899bfcd17 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Test driver: boot the tool-pwsh Loader composition, execute one real + * foreground and one real background pwsh command through the tool registry, + * and persist the observed model-visible output to `./pwsh-loader-report.json` + * for the package spec's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { CallId } from '@deepseek-ai/dsh-llm' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tool-pwsh driver requires a config path') + +const ctx = await boot('tool-pwsh-loader-smoke', resolveConfigPath(configPath, undefined)) +try { + const schema = ctx.tools.schemas().find(tool => tool.name === 'pwsh') + if (schema === undefined) throw new Error('pwsh tool not registered by the composition') + const prompt = (await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:pwsh') + + const foreground = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-fg'), + name: 'pwsh', + arguments: { command: 'Write-Output loader-ok', description: 'loader foreground' }, + }) + const foregroundText = foreground.content.filter(block => block.type === 'text').map(block => block.text).join('') + + const background = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg'), + name: 'pwsh', + arguments: { + command: 'Start-Sleep -Milliseconds 200; Write-Output loader-bg-ok', + description: 'loader background', + run_in_background: true, + }, + }) + const taskId = (background.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so accumulate both. + let backgroundText = '' + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const read = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('loader-bg-read'), + name: 'task_output', + arguments: { task_id: taskId }, + }) + backgroundText += read.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (backgroundText.includes('loader-bg-ok') && backgroundText.includes('[status: completed')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + + await writeFile('./pwsh-loader-report.json', JSON.stringify({ + schemaHasRunInBackground: Object.hasOwn(schema.parameters.properties as object, 'run_in_background'), + promptHasMarkerSection: prompt?.text.includes('Non-zero exits are reported as `[exit code: N]` markers') === true, + // Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). + foregroundText: foregroundText.replace(/\r\n/g, '\n'), + backgroundText: backgroundText.replace(/\r\n/g, '\n'), + })) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..c52dbbaf57 --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -0,0 +1,37 @@ +# Minimal keyless composition: real app, pwsh executor, and pwsh tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml new file mode 100644 index 0000000000..46a595d7ff --- /dev/null +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -0,0 +1,36 @@ +# Minimal live counterpart for the pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-pwsh-local' + +- id: bash-env + name: '@deepseek-ai/dsh-bash-env' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + # The pwsh tool replaces the bundle's bash tool in this composition. + toolBash: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/examples/package.json b/examples/package.json index 97e7918361..7cbd7d2498 100644 --- a/examples/package.json +++ b/examples/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", + "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -81,6 +83,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-pty": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index ba1d18b7e7..8cbf8ce122 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -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/examples/agent-spine-demo/README.md -README.md: 34d68b0791746c28528124853a4d8ea82b68138d -README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182 +README.md: 7ea2f4afbe5d0bea62cf0fc2c7ecdd1496d20aef +README.zh.md: cb4202ac0e72db7c85967425671144e743f31b62 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 34d68b0791..7ea2f4afbe 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to the shared `bash-env` managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 1b0a644595..cb4202ac0e 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给共享 `bash-env` 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 82178c48de..10c6ce7e55 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks-local": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", @@ -55,6 +56,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", @@ -77,6 +79,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 32e348bf75..30c51fc941 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -29,6 +29,7 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as bashEnv from '@deepseek-ai/dsh-bash-env' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -234,7 +235,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) if (config.toolBash !== false) { - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + ctx.plugin(bashEnv, { dshHome }) + ctx.plugin(toolBash, config.toolBash ?? {}) } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 670cd9a629..6a0091a6f6 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -68,6 +68,9 @@ { "path": "../../util/paths" }, + { + "path": "../../bash/bash-env" + }, { "path": "../../bash/tool-bash" }, From 4e62fea09532796abd0b5b819040bfc38ce22cbc Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:55 +0800 Subject: [PATCH 019/108] docs: record the pwsh/bash parity decision and refresh catalogs and bilingual pairs --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 4 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 4 +- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 6 ++ .../2026-08-02-pwsh-tool-bash-parity.md | 33 ++++++++ .../2026-08-02-pwsh-tool-bash-parity.zh.md | 33 ++++++++ .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 14 ++-- .../2026-08-01-windows-pwsh-default.zh.md | 14 ++-- AGENTS.md | 2 +- docs/config-catalog.md | 34 ++++++--- docs/cordis-catalog/services.md | 6 +- docs/tool-catalog.md | 76 +------------------ packages/bash/README.i18n.yaml | 4 +- packages/bash/README.md | 5 +- packages/bash/README.zh.md | 5 +- packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 3 +- packages/bash/pwsh-local/README.zh.md | 3 +- 19 files changed, 136 insertions(+), 122 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md create mode 100644 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index 4ced84e22c..d4ec255235 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: fd73e929804045d7b810a587c6f088b2f7cff9cc -2026-08-01-pwsh-tool-and-executor.zh.md: f55be1ad0e102311d09b8b7ee1a003778e679cb2 +2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 +2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index fd73e92980..7206f8ffe6 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -13,7 +13,7 @@ The harness spoke one shell dialect on every platform: `bash`. Windows hosts cou Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the minimal model-facing tool over `ctx.bash`, PowerShell-dialect by contract: foreground only, no `run_in_background`, no sandbox escalation, managed `DSH_*` environment (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`), result markers `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`, and `terminal`/`generic` UI presenters. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. @@ -30,6 +30,6 @@ The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash of ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first profile: no background tasks or escalation to mislead a model into assuming bash-tool parity, and the prompt guidance pins the `[exit code: N]` contract. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index f55be1ad0e..5a48adb79f 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -13,7 +13,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的最小面向模型工具,契约是 PowerShell 方言:仅前台,没有 `run_in_background`,没有沙箱升级,受管 `DSH_*` 环境(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`),结果标记 `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`,以及 `terminal`/`generic` UI presenter。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 @@ -30,6 +30,6 @@ Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先画像:没有后台任务或升级会让模型误以为与 bash 工具对等,提示词指导钉住 `[exit code: N]` 契约。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml new file mode 100644 index 0000000000..dcf840b868 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +2026-08-02-pwsh-tool-bash-parity.md: 3dcd1e8d4e7e6ea6841695f63012be184fa90f73 +2026-08-02-pwsh-tool-bash-parity.zh.md: 03aa9ed2109153d2e0426e7b680eb18c91f89aa7 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md new file mode 100644 index 0000000000..3dcd1e8d4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -0,0 +1,33 @@ +# Agent Note: pwsh tool bash parity + +Status: implemented + +English | [中文](2026-08-02-pwsh-tool-bash-parity.zh.md) + +## Problem + +The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only, no background tasks, no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. + +## Decision + +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: + +- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. +- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 I/O preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. +- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor), persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work), and pwsh-specific TUI/GUI presentation (generic/terminal cards stay; a PowerShell-aware terminal card with an exit pill is roadmap work). + +## Alternatives considered + +**Keep the minimal profile and fix only the claims.** Rejected: the review's core finding was that text contracts copied from bash drift without the corresponding implementation; a minimal tool plus accurate claims still leaves pwsh calls without background execution, without contributor parity, and with a divergent marker story that must be re-justified forever. + +**Extract a fully shared tool implementation base (abstract shell dialect, two thin leaves).** Considered and deferred: the bash-env extraction and the structural mirror (`render.ts`/`background.ts` twins) are the foundation it would rest on; a full base waits until a third dialect or the persistent-PTY twin makes the abstraction's shape observable. + +## Consequences + +- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. +- The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. +- The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md new file mode 100644 index 0000000000..03aa9ed210 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -0,0 +1,33 @@ +# Agent Note: pwsh 工具与 bash 对齐 + +Status: implemented + +[English](2026-08-02-pwsh-tool-bash-parity.md) | 中文 + +## 问题 + +首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台、无后台任务、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事("恒打 `[exit code: N]`")。对该变更的 review 发现模型可见契约与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。 + +## 决策 + +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: + +- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 +- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 I/O preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 +- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)、持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)、pwsh 专属 TUI/GUI 呈现(维持 generic/terminal 卡;带退出 pill 的 PowerShell 感知 terminal 卡属路线图)。 + +## 备选方案 + +**保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。 + +**提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。 + +## 后果 + +- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的"拿代码 grep 对证"检查通过。 +- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 +- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 +- 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 882e7478d3..5e7f47f278 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -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 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 6f3e48f33d98b2d2da7bd288d42a0d2763163ba3 -2026-08-01-windows-pwsh-default.zh.md: 270fd8d95c85400c302540376932228a6023447c +2026-08-01-windows-pwsh-default.md: a310174b6864bb880070280835ccfd8623e26342 +2026-08-01-windows-pwsh-default.zh.md: 079c1e3cac789a5e3fa4d0bb889026b3fb69f78c diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md index 6f3e48f33d..a310174b68 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -6,17 +6,16 @@ English | [中文](2026-08-01-windows-pwsh-default.zh.md) ## Problem -The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a minimal `pwsh` tool — but nothing yet defaults Windows hosts to them. +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. ## Proposal -Three follow-up stages, each independently shippable: +Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. 1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **Bash-tool parity twin** — `tool-pwsh` grows the bash tool's missing surface where Windows workflows prove it: `run_in_background` through the generic task runtime, and the persistence-side `DSH_SESSION_JSONL` environment fact. Sandbox escalation stays out until a Windows-confining executor exists. -3. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. +2. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. -The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then tool parity, then rendering. Nothing in this proposal changes POSIX behavior. +The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then rendering. Nothing in this proposal changes POSIX behavior. ## Alternatives considered @@ -31,11 +30,10 @@ The stages are deliberately sequenced: composition first (a Windows user gets Po - A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. - POSIX hosts are byte-for-byte unaffected (same roster, same executor). - The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 2 lands with task-runtime integration tests; stage 3 lands with TUI/Web rendering snapshots for pwsh output. +- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 lands with TUI/Web rendering snapshots for pwsh output. ## Risks - **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. -- **Tool-behavior drift** — a minimal `tool-pwsh` that never grows parity invites models to write bash-shaped commands; the prompt guidance and dialect contract mitigate this only if the twin keeps pace. - **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 3) keeps stage 1 shippable without UI churn. +- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 2) keeps stage 1 shippable without UI churn. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md index 270fd8d95c..079c1e3cac 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -6,17 +6,16 @@ Status: proposed ## 问题 -harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与最小化的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 ## 提案 -三个阶段,各自可独立交付: +两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **bash 工具对等孪生**——在 Windows 工作流证明需要的地方,`tool-pwsh` 补齐 bash 工具缺失的表面:经由通用任务运行时的 `run_in_background`,以及持久化侧 `DSH_SESSION_JSONL` 环境实情。在出现 Windows 约束执行器之前,沙箱升级保持缺席。 -3. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 +2. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 -各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再工具对等,最后渲染。本提案不改变任何 POSIX 行为。 +各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再渲染。本提案不改变任何 POSIX 行为。 ## 备选方案 @@ -31,11 +30,10 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 - POSIX 主机逐字节不受影响(清单相同,执行器相同)。 - 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 2 附带任务运行时集成测试落地;阶段 3 附带 pwsh 输出的 TUI/Web 渲染快照落地。 +- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 附带 pwsh 输出的 TUI/Web 渲染快照落地。 ## 风险 - **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 -- **工具行为漂移**——永远不补齐对等的 `tool-pwsh` 会诱使模型写 bash 形状的命令;只有当孪生跟上节奏时,提示词指导与方言契约才能缓解这一点。 - **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 -- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 3)让阶段 1 无需 UI 翻动即可交付。 +- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 2)让阶段 1 无需 UI 翻动即可交付。 diff --git a/AGENTS.md b/AGENTS.md index b7128ff89d..9d798a4c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) - bash/ bash executor seam + local impl + model-facing bash tools + bash/ bash executor seam + local/pwsh impls + model-facing shell tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f3c1fc5726..c9790212af 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,7 +192,19 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) + +## `@deepseek-ai/dsh-bash-env` + +```ts config-catalog +/** Plugin config (all optional — the built-in facts resolve without defaults). */ +export interface Config { + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string +} +``` + +Source: [`packages/bash/bash-env/src/index.ts:29`](../packages/bash/bash-env/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -991,7 +1003,7 @@ export interface Config { } ``` -Source: [`packages/bash/pwsh-local/src/index.ts:44`](../packages/bash/pwsh-local/src/index.ts) +Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -1692,19 +1704,17 @@ Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter ## `@deepseek-ai/dsh-tool-bash` -Requires: `tools` · `bash` · `systemPrompt` +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` ```ts config-catalog -/** Configuration for the bash tool and its managed child environment. */ +/** Configuration for the bash tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:34`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-bash-persistent` @@ -1844,17 +1854,17 @@ Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/i ## `@deepseek-ai/dsh-tool-pwsh` -Requires: `tools` · `bash` · `systemPrompt` +Requires: `tools` · `bash` · `systemPrompt` · `bashEnv` ```ts config-catalog -/** Plugin config (currently empty; kept as a schema so deployments can grow it). */ +/** Configuration for the pwsh tool. */ export interface Config { - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:31`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:41`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..2c6ff22cc4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -297,7 +297,7 @@ Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/inde ## `ctx.bashEnv` — `BashEnvRegistry` -Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model shell call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog /** @@ -309,7 +309,7 @@ Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The names register(contributor: BashEnvContributor): () => void /** - * Build the trusted `DSH_*` snapshot for one bash tool execution. + * Build the trusted `DSH_*` snapshot for one shell tool execution. * @param execution - the current tool execution. * @returns an immutable environment overlay containing built-ins and current contributions. */ @@ -324,7 +324,7 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) ## `ctx.clientModuleHost` — `ClientModuleHostService` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a00a2876a8..7ae66b3d37 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,8 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | +| `@deepseek-ai/dsh-tool-bash` | - | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | - | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -166,82 +166,10 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr ## `@deepseek-ai/dsh-tool-bash` -### `bash` - -Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$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]` — 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. 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`. - -```json -{ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - } - }, - "required": [ - "command", - "description" - ] -} -``` - -Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts) - The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. ## `@deepseek-ai/dsh-tool-pwsh` -### `pwsh` - -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]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. - -```json -{ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The PowerShell command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - } - }, - "required": [ - "command", - "description" - ] -} -``` - -Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) - The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 0af14fda76..66cc852e04 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -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: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b -README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea +README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0 +README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03 diff --git a/packages/bash/README.md b/packages/bash/README.md index e60ad9b0e4..ef82e9f468 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -2,13 +2,16 @@ English | [中文](README.zh.md) -The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. +The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages. | Package | Role | ctx key | |---|---|---| | `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | | `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | +| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) | +| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | +| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)). diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index deb23ea820..84ff244ec3 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -2,13 +2,16 @@ [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 +能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型的工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| | `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | | `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | +| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现(可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash`) | +| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor) | (注册 `ctx.bashEnv`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | +| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema(行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | 接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 836ab2dd84..40455ff56b 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: a97612ab4e11bf4a3fcfb77daf0624a894b02ad4 -README.zh.md: d6751dac6df789eec9727c1380f1a4c91da60728 +README.md: 430419cc34added1e983e3fc119cfeb1eebb6829 +README.zh.md: 4a1246a31a22143d6a260cc7b3024dc490f19b7f diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index a97612ab4e..430419cc34 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -28,6 +28,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **UTF-8 I/O pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. @@ -36,7 +37,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic ## Model Experience -Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures. +Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures. #### KV Cache effect diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index d6751dac6d..4a1246a31a 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -28,6 +28,7 @@ 作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **UTF-8 I/O 固定**——每条命令都先以 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`。进程树终止(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;超时/取消分类与平台无关。 @@ -36,7 +37,7 @@ ## 模型体验 -间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。 +间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。 #### KV Cache 影响 From 22c872097db5ebd334f2439e1a29c4b086004459 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:19:06 +0800 Subject: [PATCH 020/108] chore(deps): sync the lockfile and drop the resolved pwsh binary ignore --- knip.json | 1 - pnpm-lock.yaml | 85 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/knip.json b/knip.json index efc2fbea42..7cabe21ced 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,6 @@ ], "ignoreBinaries": [ "bwrap", - "pwsh", "python3", "sandbox-exec", "taskkill" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d6e3c2cb3..f7108c88b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local @@ -590,6 +593,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:* version: link:../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:* + version: link:../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -674,6 +680,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:* version: link:../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:* + version: link:../packages/bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:* version: link:../packages/guard/repeat-tool-guard @@ -791,6 +800,9 @@ importers: '@deepseek-ai/dsh-tool-pty': specifier: workspace:* version: link:../packages/pty/tool-pty + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:* + version: link:../packages/bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph @@ -895,6 +907,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/bash-env: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash-local: dependencies: schemastery: @@ -954,7 +997,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -973,7 +1016,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/tool-bash: dependencies: @@ -993,6 +1036,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local @@ -1002,9 +1048,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1014,9 +1057,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -1049,7 +1089,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1057,33 +1097,42 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../bash-env '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': + '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ - version: link:../../util/paths + version: link:../../support/loader-smoke '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../pwsh-local - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -2748,6 +2797,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -6302,6 +6354,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../packages/bash/bash-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local From 64f9e68bd96c11c0c7e2f8e394066ad5e0265df9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 15:31:52 +0800 Subject: [PATCH 021/108] test(acp): register and record the keyed pwsh-tool-turn snapshot scenario --- examples/acp-agent/tests/acp.snapshot.ts | 18 ++++++--- .../tests/snapshots/pwsh-tool-turn/input.json | 7 ++++ .../snapshots/pwsh-tool-turn/session.jsonl | 32 ++++++++++++++++ .../pwsh-tool-turn/stdout.expected.jsonl | 4 ++ .../pwsh-tool-turn/system-prompt.expected.md | 5 +++ .../pwsh-tool-turn/tool-schemas.expected.json | 38 +++++++++++++++++++ 6 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f191b5ce1b..c762e28904 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -44,6 +44,7 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -154,12 +155,17 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, - // The pwsh-tool-turn scenario is NOT registered yet: its overlay - // (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the bundle's bash tool - // for the PowerShell twin, so its header class needs its own prompt/tool - // sidecars and a recorded transcript. Both require a keyed environment - // (`test:snapshot:record`); the composition ships so the scenario can be - // registered and recorded in one keyed pass. + // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the + // bundle's bash tool for the PowerShell twin, so its header class pins its + // own prompt/tool sidecars and a recorded transcript. + { + name: 'pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'pwsh', + configPath: PWSH_CONFIG, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json new file mode 100644 index 0000000000..4101a2c1f3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..061d3261a3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"1ec0d099-552b-44e1-8fb8-fd9742fbdc1c","createdAt":1785655505943,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785655505948,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785655505948,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"e3bca81a-d4e4-46fb-aa30-916f785c27a0"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785655505949,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785655505971,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785655505972,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785655505972,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785655506674,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785655506674,"data":{"turn":1,"step":1,"index":0,"dt":[157,27,1,0,0,0,38,0,0,43,0,0,0,50,0,1,0,0,0,36,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," that"," outputs"," \"","P","WS","H","_OK","\""," and"," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":32,"time":1785655507160,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":33,"time0":1785655507161,"data":{"turn":1,"step":1,"index":1,"dt":[51,0,0,0,0,32,0,0,0,55,1,0,0,0,74,0,0,0,1,45,0,42,0,0,0,0,54,0],"id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","args":["","{","\"","command","\"",": ","\"","Write","-","Output"," P","WS","H","_OK","\"",", ","\"","description","\"",": ","\"","Output"," P","WS","H","_OK"," string","\"","}"]}} +{"type":"assistant/chunk","seq":62,"time":1785655507601,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":63,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}}}} +{"type":"assistant/chunk","seq":64,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} +{"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}} +{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\r\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":72,"time0":1785655508608,"data":{"turn":1,"step":2,"index":0,"dt":[168,38,0,60,1,0,0,0,0,27,1,0,0,0,46,0,0,0,0,0,41],"texts":["The"," command"," successfully"," output"," \"","P","WS","H","_OK","\"."," Now"," I"," should"," reply"," with"," \"","D","ONE","\""," as"," instructed","."]}} +{"type":"assistant/chunk","seq":94,"time":1785655508990,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":95,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."}}}} +{"type":"assistant/chunk","seq":98,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":99,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":100,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":101,"time":1785655508991,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"d8905016-347c-477a-a8f7-2b31839a8357"},"usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} +{"type":"step/end","seq":102,"time":1785655508992,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":103,"time":1785655508992,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..9b51477daf --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,5 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a concise snapshot agent working in {{cwd}}. + +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..6f6d3fa729 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,38 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "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. 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. 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`.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] + } + } + ], + "changes": [] +} From 8fa1201f6eeaec72bbe4e08d49a965da231ffd07 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 16:06:25 +0800 Subject: [PATCH 022/108] chore: trigger CI merge-ref recalculation From ab4963c43db67273cdb7314cee99bfe8e750ae9a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 16:25:27 +0800 Subject: [PATCH 023/108] fix(scripts): mount bash-env in the tool-catalog harvest --- docs/tool-catalog.md | 80 ++++++++++++++++++++++++++++++++++++- scripts/gen-tool-catalog.ts | 3 ++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 5a7b47719c..56a6eaaedc 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,8 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | - | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-pwsh` | - | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -168,10 +168,86 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr ## `@deepseek-ai/dsh-tool-bash` +### `bash` + +Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$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]` — 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. 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`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts) + The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. ## `@deepseek-ai/dsh-tool-pwsh` +### `pwsh` + +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. 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. 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`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) + The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. ## `@deepseek-ai/dsh-tool-cordis` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 26d751591f..9af8fbe7c2 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,6 +19,7 @@ import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -196,6 +197,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, @@ -213,6 +215,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ // mounts the pwsh-local implementation so the inject resolves without // executing anything (registration never spawns a process). await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(PwshLocalExecutor) await ctx.plugin(ToolPwsh) }, From cdb2aac382cb20116a2d7380b2cb471267dc0aa5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 16:39:52 +0800 Subject: [PATCH 024/108] docs(module-graph): refresh after the master merge --- docs/module-graph.md | 121 +++++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 2da7b99e6d..5c709fc06f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,6 +38,7 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] + pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] @@ -697,26 +698,11 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_paths - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_session_persistence - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_bash - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_paths - pkg_tool_pwsh --> pkg_session_persistence - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tools + pkg_bash_env --> pkg_bash + pkg_bash_env --> pkg_invariants + pkg_bash_env --> pkg_paths + pkg_bash_env --> pkg_session_persistence + pkg_bash_env --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -905,6 +891,25 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_bash_env + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_bash_env + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tasks + pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -994,27 +999,6 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1050,6 +1034,39 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1070,17 +1087,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1210,8 +1216,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1243,6 +1248,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1254,14 +1261,14 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | From 480c12077f0e510b65d890b8205e73124b63d497 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 16:43:30 +0800 Subject: [PATCH 025/108] fix(tool-bash): mount bash-env in the base test harness --- packages/bash/tool-bash/tests/tools.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 22fe3bc4b6..b463bf4ef2 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -36,6 +36,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -51,6 +52,7 @@ async function setupWithTasks() { await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx From a79d7c896ce3e0c67207dd1a62ef0e37ee675af7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 16:53:29 +0800 Subject: [PATCH 026/108] fix(tool-bash): mount bash-env in the sandboxed and HMR-safety harnesses; normalize the pwsh-tool-turn fixture line endings for Linux replay --- examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl index 061d3261a3..333948541c 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} {"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}} -{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\r\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} {"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}} {"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index b463bf4ef2..30a997b975 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -191,6 +191,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } @@ -393,6 +394,7 @@ describe('bash tool', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(BashEnvPlugin) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) From 7201a417c2c9cb15ae62e9baa746c088d92a1a02 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 17:25:47 +0800 Subject: [PATCH 027/108] fix(subagent): mount bash-env in the spawn and headless e2e harnesses --- examples/headless-agent/tests/harness.ts | 2 ++ packages/subagent/subagent-spawn/tests/harness.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index c354205388..756cc58e39 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -61,6 +62,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index afa1d2a1d2..389ef5e2a7 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -29,6 +30,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) From eeffd2cfcef6ce54f9e2ee1eb26b23dcb72d08d7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 17:35:46 +0800 Subject: [PATCH 028/108] fix(headless): mount bash-env in the code-mode harnesses --- examples/headless-agent/tests/code-mode.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 1f708ab601..b8362fc6f8 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -57,6 +58,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -117,6 +119,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessService) + await harness.plugin(BashEnvPlugin) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness From 447c3fd17fe72ac7b52b549510cd170304e81617 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 17:49:58 +0800 Subject: [PATCH 029/108] fix(sdk): include bash-env in the generated bash feature composition --- packages/sdk/helper/src/features/builtin/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 2aa6c40e71..d2e1068ce4 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -34,6 +34,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry required: true, baseResources: [ { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, + { kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' }, { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, ], options: [ From 48f0881e441cb7e75680f809882b1fd312e6cfc5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 18:17:30 +0800 Subject: [PATCH 030/108] docs: describe the minimal pwsh profile as no persistent PTY, not no background tasks --- .../feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 2 +- .../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index dcf840b868..582d3fb592 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 3dcd1e8d4e7e6ea6841695f63012be184fa90f73 +2026-08-02-pwsh-tool-bash-parity.md: f5e3ecfa7e34240ef226f0bceda5d83194667744 2026-08-02-pwsh-tool-bash-parity.zh.md: 03aa9ed2109153d2e0426e7b680eb18c91f89aa7 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 3dcd1e8d4e..f5e3ecfa7e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-02-pwsh-tool-bash-parity.zh.md) ## Problem -The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only, no background tasks, no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. +The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately minimal profile — foreground only (a fresh process per call; no persistent PTY session), no managed-environment parity beyond three hardcoded `DSH_*` keys, and a marker story ("always `[exit code: N]`") that diverged from the bash tool's rendering without being declared. Review of that change found the model-visible contract drifting from the implementation: the description promised spill-path reporting the renderer never performed, the README claimed exports that did not exist and rendering the tool did not do, and the tool's own tests pinned the lossy behavior. The minimal profile also left the `DSH_*` contributor seam duplicated-by-absence: plugins contributing environment facts to `ctx.bashEnv` had no effect on pwsh calls. ## Decision From a30f3f1a04d3a559041dac89ff62d6b098967e0d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 19:00:51 +0800 Subject: [PATCH 031/108] docs(catalog): refresh the pwsh tool entry to the parity tool and fix requires lists --- docs/tool-catalog.md | 6 +++--- scripts/gen-tool-catalog.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 56a6eaaedc..27115e9f28 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,8 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | @@ -248,7 +248,7 @@ Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Eac Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts) -The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. +The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. ## `@deepseek-ai/dsh-tool-cordis` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 9af8fbe7c2..1692b9fead 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -193,7 +193,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', source: 'packages/bash/tool-bash/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalSubprocessService) @@ -208,7 +208,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-pwsh', dir: 'tool-pwsh', source: 'packages/bash/tool-pwsh/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { // The pwsh tool consumes the bash executor seam; the schema harvest @@ -220,7 +220,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolPwsh) }, note: - 'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\\...` paths and `$env:NAME` variables.', + 'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.', }, { pkg: '@deepseek-ai/dsh-tool-cordis', From c376802f44d4588781963c1a37e7e06c30028a34 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 19:37:09 +0800 Subject: [PATCH 032/108] fix(acp-snapshot): skip pwshOnly scenarios without pwsh and mount task tools in the pwsh composition --- docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 9 ++++ .../acp-agent/tests/pwsh.cordis.snapshot.yml | 2 +- examples/acp-agent/tests/pwsh.cordis.yml | 2 +- .../snapshots/pwsh-tool-turn/session.jsonl | 2 +- .../pwsh-tool-turn/system-prompt.expected.md | 2 + .../pwsh-tool-turn/tool-schemas.expected.json | 52 +++++++++++++++++++ packages/support/acp-snapshot/src/suite.ts | 28 ++++++++-- .../support/acp-snapshot/tests/suite.spec.ts | 8 +++ 11 files changed, 101 insertions(+), 12 deletions(-) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index df4b94e6ce..ba8b076346 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -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 docs/testing.md -testing.md: 8c16dea5e90ff330992a2d0e38f47abc9da20b26 -testing.zh.md: f99f02e2a733a94cadeffddc2c5033242bfde59b +testing.md: e441b4f467b031aecb595b86664ec8d7aeddf2c7 +testing.zh.md: f787cf0c3131acc8f4ddaf4da2f6205f5a0f0a48 diff --git a/docs/testing.md b/docs/testing.md index 8c16dea5e9..e441b4f467 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock-only compositions, and PR rationale do not replace the assembled transcript. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness expresses it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index f99f02e2a7..f787cf0c31 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 41e34acc42..7e36e854d6 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' @@ -168,6 +169,9 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'pwsh', configPath: PWSH_CONFIG, + // The composition boots the real pwsh executor; hosts without a `pwsh` + // binary skip the run (fixtures stay guarded). + pwshOnly: true, }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { @@ -431,11 +435,16 @@ const SCENARIOS: Scenario[] = [ }, ] +// Hosts without a `pwsh` binary skip the pwsh-tool-turn run (its fixtures +// stay guarded); the probe follows the executor's own resolution. +const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: SNAPSHOTS_DIR, scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + hasPwsh, }) it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml index c52dbbaf57..91fdeabf53 100644 --- a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -27,7 +27,7 @@ workspaceContext: false skills: enabled: false - toolTasks: false +# task_output/task_kill stay mounted so background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index 46a595d7ff..cb8305c7d9 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -26,7 +26,7 @@ workspaceContext: false skills: enabled: false - toolTasks: false +# task_output/task_kill stay mounted so background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl index 333948541c..061d3261a3 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} {"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}} -{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\r\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} {"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}} {"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md index 9b51477daf..f354648c41 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/system-prompt.expected.md @@ -3,3 +3,5 @@ You are an AI agent powered by the DeepSeek Harness SDK. You are a concise snapshot agent working in {{cwd}}. Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json index 6f6d3fa729..611de722e3 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/tool-schemas.expected.json @@ -32,6 +32,58 @@ "description" ] } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } } ], "changes": [] diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c8434bb8c9..f9e97dd45b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -161,25 +161,37 @@ export interface Scenario { * test is skipped on Windows; its fixtures stay guarded on every platform. */ posixOnly?: boolean + /** + * Whether the scenario boots a composition that needs a real `pwsh` on PATH + * (the pwsh-tool-turn scenario). The run test is skipped when the suite's + * {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded + * on every platform. + */ + pwshOnly?: boolean } /** * Whether a scenario's run test is skipped for this mode and host: record mode - * skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly} - * scenarios skip on Windows. + * skips authored (non-`recorded`) scenarios, {@link Scenario.posixOnly} + * scenarios skip on Windows, and {@link Scenario.pwshOnly} scenarios skip + * when the caller's `hasPwsh` probe is false. * * @param scenario The scenario whose run test is being registered. * @param recording Whether the suite runs in record mode. * @param platform The running Node platform, injectable for unit coverage. + * @param hasPwsh The caller's pwsh-availability probe; `pwshOnly` scenarios + * skip unless it is true. * @returns True when the scenario's run test must not execute. */ export function scenarioSkipped( scenario: Scenario, recording: boolean, platform: NodeJS.Platform = process.platform, + hasPwsh?: boolean, ): boolean { if (recording && !scenario.recorded) return true - return scenario.posixOnly === true && platform === 'win32' + if (scenario.posixOnly === true && platform === 'win32') return true + return scenario.pwshOnly === true && hasPwsh !== true } /** One stdout expected output selected for a platform run. */ @@ -220,6 +232,11 @@ export interface SnapshotSuiteOptions { * from `$DSH_SNAPSHOT` — env reading stays outside this library. */ mode: 'replay' | 'record' | 'refresh' + /** + * Whether a real `pwsh` executable is available on this host (the probe the + * caller owns; `pwshOnly` scenarios skip when this is not true). + */ + hasPwsh?: boolean } /** One scenario's generated claim on a shared snapshot file. */ @@ -973,8 +990,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows. - it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows; + // `pwshOnly` scenarios skip when the caller's `hasPwsh` probe is false. + it.skipIf(scenarioSkipped(scenario, RECORDING, process.platform, options.hasPwsh))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6f16844595..d8cb177a16 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -459,6 +459,7 @@ describe('stdoutExpectedVariants', () => { describe('scenarioSkipped', () => { const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false } const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true } + const pwsh: Scenario = { name: 'pwsh-tool', hasModelTurn: true, recorded: false, pwshOnly: true } it('skips authored scenarios only while recording', () => { expect(scenarioSkipped(authored, true, 'linux')).toBe(true) @@ -471,6 +472,13 @@ describe('scenarioSkipped', () => { expect(scenarioSkipped(posix, false, 'darwin')).toBe(false) expect(scenarioSkipped(authored, false, 'win32')).toBe(false) }) + + it('skips pwshOnly scenarios when the host lacks pwsh, and runs them otherwise', () => { + expect(scenarioSkipped(pwsh, false, 'linux', false)).toBe(true) + expect(scenarioSkipped(pwsh, false, 'win32', true)).toBe(false) + expect(scenarioSkipped(pwsh, false, 'linux', true)).toBe(false) + expect(scenarioSkipped(authored, false, 'linux', false)).toBe(false) + }) }) describe('fixtureContext', () => { From d8ee37d87a48de06cf80a3f1c71464608fca327e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 19:37:25 +0800 Subject: [PATCH 033/108] fix(tool-pwsh): present background calls on the generic card like the bash tool --- packages/bash/tool-pwsh/src/index.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index c8a02f0428..30ab72482f 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -18,7 +18,7 @@ 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 { TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, 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' @@ -276,12 +276,25 @@ export function apply(ctx: Context, config: Config = {}): void { return canonicalPwshResult(result) }, /* jscpd:ignore-end */ - presentCall: (args: PwshToolArgs): TerminalCallView => ({ - card: 'terminal', - title: args.command, - description: args.description, - ...args.workdir !== undefined ? { cwd: args.workdir } : {}, - }), + presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => { + // Background acknowledgements carry no terminal exit status; the generic + // card mirrors the bash tool's background presentation. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, + } + }, presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined From 12c6f43bf1f65e441b33fba44d3b1684ad2f3e8e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 19:37:29 +0800 Subject: [PATCH 034/108] fix(tool-bash): report foreground aborts as the TOOL_ABORTED HarnessError --- packages/bash/tool-bash/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index c2a5c3e288..91a88c9cca 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -381,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void { ...request, signal: exec.signal, })) - if (result.aborted) throw new Error('command aborted') + if (result.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, From 5e437d399680a2e784d6f39c6ea33cdd056f6bc7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 19:37:45 +0800 Subject: [PATCH 035/108] docs(pwsh): scope the encoding claim, document the PATH probe and preamble limitation, and fix catalog requires lists --- .../feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 ++-- .../feature/2026-08-02-pwsh-tool-bash-parity.md | 2 +- .../feature/2026-08-02-pwsh-tool-bash-parity.zh.md | 2 +- docs/config-catalog.md | 5 +++-- docs/tool-catalog.md | 4 ++-- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 3 ++- packages/bash/pwsh-local/README.zh.md | 3 ++- packages/bash/pwsh-local/src/index.ts | 7 ++++--- scripts/gen-tool-catalog.ts | 4 ++-- 10 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 582d3fb592..913cf0cc06 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: f5e3ecfa7e34240ef226f0bceda5d83194667744 -2026-08-02-pwsh-tool-bash-parity.zh.md: 03aa9ed2109153d2e0426e7b680eb18c91f89aa7 +2026-08-02-pwsh-tool-bash-parity.md: fb0f3fff1ed00dfde286781733881facfb1e6c7b +2026-08-02-pwsh-tool-bash-parity.zh.md: 9dc84bcd5e53c52d5d68b2db61cf79fe7fb97bb9 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index f5e3ecfa7e..fb0f3fff1e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -15,7 +15,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. - **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. -- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 I/O preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. +- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. - **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor), persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work), and pwsh-specific TUI/GUI presentation (generic/terminal cards stay; a PowerShell-aware terminal card with an exit pill is roadmap work). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 03aa9ed210..9dc84bcd5e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -15,7 +15,7 @@ Status: implemented - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 - **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 -- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 I/O preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 +- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 - **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)、持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)、pwsh 专属 TUI/GUI 呈现(维持 generic/terminal 卡;带退出 pill 的 PowerShell 感知 terminal 卡属路线图)。 ## 备选方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2149981ad3..f14a7a5900 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -996,8 +996,9 @@ export interface Config { graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install - * locations are probed first (PowerShell 7, then Windows PowerShell 5.1), - * falling back to a bare `pwsh` resolved through PATH. + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. */ pwshPath?: string } diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 27115e9f28..38125bcd4a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -18,8 +18,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | -| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | +| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 40455ff56b..d58935d285 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: 430419cc34added1e983e3fc119cfeb1eebb6829 -README.zh.md: 4a1246a31a22143d6a260cc7b3024dc490f19b7f +README.md: bb5d941b0cc98fb14e0b47d5bbe0bffb5192edd8 +README.zh.md: efd8aac2b8766d1082b85905fce9867151b77310 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 430419cc34..bb5d941b0c 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -28,7 +28,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. -- **UTF-8 I/O pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. pwsh 7 defaults to UTF-8 and is unaffected. +- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. @@ -50,5 +50,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. - **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)` and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; prefix such scripts with a no-op statement (or `& { … }`) first. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index 4a1246a31a..efd8aac2b8 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -28,7 +28,7 @@ 作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 -- **UTF-8 I/O 固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。pwsh 7 默认为 UTF-8,不受影响。 +- **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`。进程树终止(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;超时/取消分类与平台无关。 @@ -50,5 +50,6 @@ - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 - **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;请先用无操作语句(或 `& { … }`)开头。 清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index b93bbb8d89..b966a579af 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -35,7 +35,7 @@ export const ENV_OVERRIDES = { } as const /** - * UTF-8 I/O pinning prepended to every command. The subprocess collector + * UTF-8 output pinning prepended to every command. The subprocess collector * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort * executable fallback) writes the console/OEM code page by default, which * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The @@ -67,8 +67,9 @@ export interface Config { graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install - * locations are probed first (PowerShell 7, then Windows PowerShell 5.1), - * falling back to a bare `pwsh` resolved through PATH. + * locations and PATH entries are probed in order (PowerShell 7 install, + * PATH entries such as the Microsoft Store install, then Windows + * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH. */ pwshPath?: string } diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 1692b9fead..88e07f697c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -193,7 +193,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', source: 'packages/bash/tool-bash/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalSubprocessService) @@ -208,7 +208,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-pwsh', dir: 'tool-pwsh', source: 'packages/bash/tool-pwsh/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { // The pwsh tool consumes the bash executor seam; the schema harvest From ed39ff096fe6424853bc41ce16cc945dba321119 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 20:07:26 +0800 Subject: [PATCH 036/108] test(fixtures): pin the TOOL_ABORTED message and keep the pwsh fixture replayable; restore trimmed testing.md clauses --- docs/testing.i18n.yaml | 4 ++-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 2 +- .../acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl | 2 +- .../acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl | 2 +- scripts/doc-budgets.manifest.json | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index ba8b076346..3d7569c620 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -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 docs/testing.md -testing.md: e441b4f467b031aecb595b86664ec8d7aeddf2c7 -testing.zh.md: f787cf0c3131acc8f4ddaf4da2f6205f5a0f0a48 +testing.md: d460d9ba76eab84b79e8c66454325d03d7c5908e +testing.zh.md: 821e49b6593b924e754d5ed0d588dd1b5b566051 diff --git a/docs/testing.md b/docs/testing.md index e441b4f467..d460d9ba76 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock-only compositions, and PR rationale do not replace the assembled transcript. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness expresses it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness expresses it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index f787cf0c31..821e49b659 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 7e36e854d6..824780ae0f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -436,7 +436,7 @@ const SCENARIOS: Scenario[] = [ ] // Hosts without a `pwsh` binary skip the pwsh-tool-turn run (its fixtures -// stay guarded); the probe follows the executor's own resolution. +// stay guarded); a bare `pwsh` probe keeps this suite dependency-light. const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 defineAcpSnapshotSuite({ diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 5fe449ae48..a26655947d 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl index 061d3261a3..333948541c 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} {"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}} -{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\r\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} {"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}} {"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 5c88ab3fc2..4387a894f1 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1100, + "docs/testing.md": 1120, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, "packages/README.md": 920 From efcee43c7d6fadef21f10bed10d7715019b2cdcf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 20:07:43 +0800 Subject: [PATCH 037/108] docs(tools): document the abort message and the background call card; pin both with tests --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/tool-bash/README.i18n.yaml | 4 ++-- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/README.zh.md | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 7 +++++-- packages/bash/tool-pwsh/README.i18n.yaml | 4 ++-- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 16 ++++++++++++++++ 11 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index d58935d285..49d38f2b25 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: bb5d941b0cc98fb14e0b47d5bbe0bffb5192edd8 -README.zh.md: efd8aac2b8766d1082b85905fce9867151b77310 +README.md: c5e0cc524e32d2218e734539c74adcddb663d4f3 +README.zh.md: 7906d8a511ee72ecdffaddaf88ddebe195a20857 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index bb5d941b0c..c5e0cc524e 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -50,6 +50,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. - **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. -- **The encoding preamble precedes the command** — PowerShell requires `param(...)` and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; prefix such scripts with a no-op statement (or `& { … }`) first. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; prefix such scripts with a no-op statement (or `& { … }`) first. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index efd8aac2b8..7906d8a511 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -50,6 +50,6 @@ - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 - **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 -- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;请先用无操作语句(或 `& { … }`)开头。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;请先用无操作语句(或 `& { … }`)开头。 清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 9d53b52d02..fc7f0d443e 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d -README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57 +README.md: e3c8c445c8959b7f49705bb59af2bf61e0e716c8 +README.zh.md: fc5b3794187dcfd1f8b382bc805595c8ae887c55 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 29b9fba369..e3c8c445c8 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -141,7 +141,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. 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 `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: `. 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 `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`. #### Token effect diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 31f691f7bf..fc5b379418 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -141,7 +141,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr #### 模型看到的内容 -验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 +验证和策略失败统一为 `Error: `。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "" is not strictly wider than this call's current "" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 #### Token 影响 diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 30a997b975..e6228ceca8 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -305,7 +305,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(/ENOENT/) }) - it('surfaces foreground aborts as isError', async () => { + it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => { const ctx = await setup() const controller = new AbortController() const pending = ctx.tools.execute({ @@ -317,7 +317,10 @@ describe('bash tool', () => { setTimeout(() => { controller.abort() }, 50) const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toMatch(/aborted/) + expect(result.error).toMatchObject({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) }) // Type and required-key violations are rejected by the harness diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 102e50d0ce..030d24c7c2 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -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: b5acc73a68d3b309860554d4c1e8d979eb8d1eec -README.zh.md: 4d678c42194b78da8b4b10e01f8b9e666d6236d8 +README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 +README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index b5acc73a68..dfe26a6368 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -36,7 +36,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. ## Model Experience diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 4d678c4219..2344f8477e 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -36,7 +36,7 @@ ## UI presentation -工具拥有自己的 `presentCall`/`presentResult` 呈现意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡;完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 ## Model Experience diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 9d1eedba08..218099326f 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -537,6 +537,22 @@ describe('UI presentation', () => { .toMatchObject({ cwd: 'C:\\work' }) }) + it('a background pending call renders the generic card like the bash tool', async () => { + const { ctx } = await setup() + const definition = ctx.tools.get('pwsh') + expect(definition?.presentCall?.({ + command: 'Start-Sleep -Seconds 60', + description: 'long wait', + run_in_background: true, + })).toEqual({ + card: 'generic', + title: 'Start-Sleep -Seconds 60', + kind: 'execute', + rawInput: 'Start-Sleep -Seconds 60', + content: [{ type: 'text', text: 'long wait' }], + }) + }) + it('presentResult falls back to undefined for multi-block or non-text content', async () => { const { ctx } = await setup() const definition = ctx.tools.get('pwsh') From 6ae0f78c2ad792394408c8a54245dd03a5df1a65 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 20:27:06 +0800 Subject: [PATCH 038/108] fix(tools): pin the abort error info in the cancel fixture, close the jscpd mirror block, and restore the pwsh binary ignore --- .../acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl | 2 +- knip.json | 1 + packages/bash/tool-pwsh/src/index.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index a26655947d..a05cd1c28f 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e","error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} diff --git a/knip.json b/knip.json index 71ee4faa3c..3ad4bad366 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "pwsh", "python3", "sandbox-exec", "taskkill" diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 30ab72482f..9423fe36e6 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -276,6 +276,7 @@ export function apply(ctx: Context, config: Config = {}): void { return canonicalPwshResult(result) }, /* jscpd:ignore-end */ + /* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */ presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => { // Background acknowledgements carry no terminal exit status; the generic // card mirrors the bash tool's background presentation. @@ -295,6 +296,7 @@ export function apply(ctx: Context, config: Config = {}): void { ...args.workdir !== undefined ? { cwd: args.workdir } : {}, } }, + /* jscpd:ignore-end */ presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined From 7b7525c0912de8a4f9af4fd63e69e7bdc188e41b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 21:28:22 +0800 Subject: [PATCH 039/108] fix(fixtures): repair the cancel-fixture abort error placement; exempt pwsh-local coverage on pwsh-less hosts The hand-edit in 348ab41151 put the seq-17 abort error inside the tool-result message and dropped a closing brace, breaking every JSONL consumer; tool-calls.ts appends 'error' as a data-level sibling of 'message' (the seq-19 shape), so the fixture now matches the emitter. Coverage: pwsh-local's executor suites self-skip without a real pwsh, which left per-file 100% unreachable on pwsh-less contributor hosts (mirror of the existing windowsCoverageExclusions contract). A PATH-only probe exempts only pwsh-local/src/index.ts; CI runners ship pwsh and still enforce the full bar. docs/testing.md (+zh, pairing re-recorded) names the prerequisite; the testing.md budget rises 1120->1150 because the coverage-gate contract genuinely grew. --- docs/testing.i18n.yaml | 4 ++-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- .../snapshots/cancel-tool-calls/session.jsonl | 2 +- scripts/doc-budgets.manifest.json | 2 +- vitest.config.ts | 14 ++++++++++++++ 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 3d7569c620..a5160543fb 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -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 docs/testing.md -testing.md: d460d9ba76eab84b79e8c66454325d03d7c5908e -testing.zh.md: 821e49b6593b924e754d5ed0d588dd1b5b566051 +testing.md: 5e766bb684d9d76a1c9e854a2aef95796a31ec12 +testing.zh.md: 21b3f98e9ddbbb6f77621cff9ce206ae17125bc3 diff --git a/docs/testing.md b/docs/testing.md index d460d9ba76..5e766bb684 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). -- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. +- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 821e49b659..21b3f98e9d 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -7,7 +7,7 @@ ## 层级 - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 -- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index a05cd1c28f..d66020b594 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785487611319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785487611319,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bd630f41-b45d-4183-a785-1ff6e7049b62"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[7,8,9,10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785487611319,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e","error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_wait"},"content":[{"type":"tool-result","toolCallId":"call_wait","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true}],"role":"user","id":"252903b2-b4e1-4a33-81d8-d5befefcb27e"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785487611378,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":19,"time":1785487611378,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skipped"},"content":[{"type":"tool-result","toolCallId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true}],"role":"user","id":"282c5c8c-7296-4545-8014-e6393b351436"},"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785487611378,"data":{"turn":1,"step":1}} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4387a894f1..9e8d6076ec 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1120, + "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, "packages/README.md": 920 diff --git a/vitest.config.ts b/vitest.config.ts index 14d5be103f..458b1134b1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' import { vitestExecArgv } from './vitest.shared.ts' @@ -39,6 +40,18 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites +// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file +// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts +// green while CI runners ship pwsh and still enforce the full bar. The probe +// is deliberately PATH-only (narrower than the suites' resolvePwshPath): a +// win32 host where only install-location pwsh or 5.1 resolves forfeits the +// exemption while the suites still run, so the gate can only get stricter, +// never falsely green. +const pwshCoverageExclusions = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + ? [] + : ['packages/bash/pwsh-local/src/index.ts'] + const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', 'apps/*/tests/**/*.spec.ts', @@ -203,6 +216,7 @@ export default defineConfig({ 'packages/ui/tui/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. From b2838fb2beb2727fb59e634976e81de37963a639 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 21:46:53 +0800 Subject: [PATCH 040/108] fix(pwsh): close the remaining review threads - scrubbedParentEnv folds case before the DSH_ prefix check (Windows env names are case-insensitive; a parent dsh_* entry read back as \* in the child) and the service spec pins the lowercase probe. - The acp.snapshot.ts pwsh probe follows resolvePwshPath() like the package suites, so a Windows host with only an install-location pwsh still runs the scenario. - pwsh-tool-turn is re-recorded around [Console]::Out.Write('PWSH_OK'): the fixture carries no platform newline, so one recording replays on Windows and POSIX alike (record + refresh; replay-verified keyless). - The pwsh-local Known Limitations bullet drops the self-defeating no-op advice: & { } is scoped to param(...), using/#requires scripts run from a file (both languages, pairing re-recorded). - The capability-seams graph moves ctx.bashEnv ownership to bash-env and lists pwsh-local/tool-pwsh on the ctx.bash seam (source updated, docs regenerated). - The tool-bash presenter fixture retires the stale 'command aborted' literal for the shipped 'tool call aborted' message. --- docs/capability-seams.md | 13 +++- examples/acp-agent/tests/acp.snapshot.ts | 12 ++-- .../tests/snapshots/pwsh-tool-turn/input.json | 2 +- .../snapshots/pwsh-tool-turn/session.jsonl | 64 +++++++++---------- packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 4 +- packages/subprocess/subprocess/src/index.ts | 7 +- .../subprocess/tests/service.spec.ts | 5 +- scripts/gen-doc-graphs.ts | 11 ++-- 11 files changed, 72 insertions(+), 54 deletions(-) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 561b59e10e..6b69cae609 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -108,6 +108,9 @@ flowchart LR pkg_subagent_acp["subagent-acp"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] + pkg_pwsh_local["pwsh-local"] + pkg_tool_pwsh["tool-pwsh"] + pkg_bash_env["bash-env"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -170,6 +173,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash + pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime @@ -197,6 +201,7 @@ flowchart LR pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty pkg_pty_local --> svc_pty + pkg_pwsh_local --> svc_bash pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy @@ -234,7 +239,6 @@ flowchart LR pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_tui --> svc_tui pkg_tui --> svc_userInteraction @@ -260,6 +264,9 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_bash --> pkg_tool_pwsh + svc_bashEnv --> pkg_tool_bash + svc_bashEnv --> pkg_tool_pwsh svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui @@ -381,8 +388,8 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | -| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | -| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | +| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 824780ae0f..c107a1e2b0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' /** @@ -170,7 +171,9 @@ const SCENARIOS: Scenario[] = [ headerClass: 'pwsh', configPath: PWSH_CONFIG, // The composition boots the real pwsh executor; hosts without a `pwsh` - // binary skip the run (fixtures stay guarded). + // binary skip the run (fixtures stay guarded). The recorded turn writes + // PWSH_OK via [Console]::Out.Write so the fixture carries no platform + // newline and one recording replays on every host. pwshOnly: true, }, { name: 'todo-write', hasModelTurn: true, recorded: true }, @@ -435,9 +438,10 @@ const SCENARIOS: Scenario[] = [ }, ] -// Hosts without a `pwsh` binary skip the pwsh-tool-turn run (its fixtures -// stay guarded); a bare `pwsh` probe keeps this suite dependency-light. -const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +// Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures +// stay guarded); the probe follows the executor's own resolution so a Windows +// host with only an install-location pwsh still runs the scenario. +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 defineAcpSnapshotSuite({ agent: AGENT, diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json index 4101a2c1f3..653e9a346c 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop." } + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } ] } diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl index 333948541c..9dc17ef799 100644 --- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl @@ -1,32 +1,32 @@ -{"type":"session","version":0,"id":"1ec0d099-552b-44e1-8fb8-fd9742fbdc1c","createdAt":1785655505943,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785655505948,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785655505948,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"e3bca81a-d4e4-46fb-aa30-916f785c27a0"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785655505949,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785655505971,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785655505972,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785655505972,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785655506674,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":7,"time0":1785655506674,"data":{"turn":1,"step":1,"index":0,"dt":[157,27,1,0,0,0,38,0,0,43,0,0,0,50,0,1,0,0,0,36,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," that"," outputs"," \"","P","WS","H","_OK","\""," and"," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":32,"time":1785655507160,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":33,"time0":1785655507161,"data":{"turn":1,"step":1,"index":1,"dt":[51,0,0,0,0,32,0,0,0,55,1,0,0,0,74,0,0,0,1,45,0,42,0,0,0,0,54,0],"id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","args":["","{","\"","command","\"",": ","\"","Write","-","Output"," P","WS","H","_OK","\"",", ","\"","description","\"",": ","\"","Output"," P","WS","H","_OK"," string","\"","}"]}} -{"type":"assistant/chunk","seq":62,"time":1785655507601,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":63,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}}}} -{"type":"assistant/chunk","seq":64,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"} -{"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}} -{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"} -{"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":72,"time0":1785655508608,"data":{"turn":1,"step":2,"index":0,"dt":[168,38,0,60,1,0,0,0,0,27,1,0,0,0,46,0,0,0,0,0,41],"texts":["The"," command"," successfully"," output"," \"","P","WS","H","_OK","\"."," Now"," I"," should"," reply"," with"," \"","D","ONE","\""," as"," instructed","."]}} -{"type":"assistant/chunk","seq":94,"time":1785655508990,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":95,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":96,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":97,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."}}}} -{"type":"assistant/chunk","seq":98,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":99,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":100,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":101,"time":1785655508991,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"d8905016-347c-477a-a8f7-2b31839a8357"},"usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} -{"type":"step/end","seq":102,"time":1785655508992,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":103,"time":1785655508992,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785678162244,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785678162245,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"6efbdc24-7abe-4f34-ac6d-15f93d49ad9a"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785678162246,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785678162261,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785678162262,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785678162968,"data":{"turn":1,"step":1,"index":0,"dt":[393,0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":29,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":30,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,0,0,0,0,275,0,1,0,0,0,0,0,0,0],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"",", ","\"","description","\"",": ","\"","Write"," P","WS","H","_OK"," to"," console","\"","}"]}} +{"type":"assistant/chunk","seq":65,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":66,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}}} +{"type":"assistant/chunk","seq":67,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":68,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1785678164126,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"6e968eea-46b8-4489-8005-5e898d53c1a9"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1785678164127,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}} +{"type":"tool/result","seq":71,"time":1785678164405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"964dfad5-651e-47f0-90a5-fe5bc711a3ff"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1785678164405,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1785678164410,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1785678165135,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":75,"time0":1785678165136,"data":{"turn":1,"step":2,"index":0,"dt":[176,44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":100,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":101,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":102,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":103,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":104,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":105,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":106,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":107,"time":1785678165693,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"91f35706-53e9-4fcd-891e-2c9eafccde98"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106],"surfaceOp":"append"} +{"type":"step/end","seq":108,"time":1785678165694,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":109,"time":1785678165694,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 49d38f2b25..0d11b89d6b 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: c5e0cc524e32d2218e734539c74adcddb663d4f3 -README.zh.md: 7906d8a511ee72ecdffaddaf88ddebe195a20857 +README.md: e061d1bd614fa4aa13d3ae9418c2e4af47d72518 +README.zh.md: 3a8d2b7ea41c02dbaf72273b0e9ef0f2c023068f diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index c5e0cc524e..e061d1bd61 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -50,6 +50,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. - **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. -- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; prefix such scripts with a no-op statement (or `& { … }`) first. +- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index 7906d8a511..3a8d2b7ea4 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -50,6 +50,6 @@ - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 - **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 -- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;请先用无操作语句(或 `& { … }`)开头。 +- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index e6228ceca8..4f913d78ee 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -1017,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'text', text: 'command aborted' }], isError: true }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, ) - expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 5d0bbe2c76..d484faa24d 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -45,7 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i * `HOME`, locale, and proxy variables survive, so child CLIs run normally; * harness identity never leaks implicitly (a deliberately forwarded * credential or current `DSH_*` fact goes through the spec's explicit `env`, - * which merges after this scrub). Exported as a plain function so spawners + * which merges after this scrub). Both scrubs match case-insensitively: + * Windows environment names are case-insensitive, so a parent `dsh_*` entry + * would otherwise survive and read back as `$env:DSH_*` in the child; + * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. * @returns a fresh environment object safe to hand to a child spawn. @@ -53,7 +56,7 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i export function scrubbedParentEnv(): Record { const env: Record = {} for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value } return env } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 28a1dce299..d0ef5c9fd6 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -52,20 +52,23 @@ describe('SubprocessService seam', () => { await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) }) - it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + it('scrubbedParentEnv drops credential-shaped and DSH_ names (case-insensitively) but keeps PATH', () => { process.env.DSH_SCRUB_PROBE = 'stale' + process.env.dsh_scrub_probe_lower = 'stale' process.env.SCRUB_PROBE_TOKEN = 'secret' process.env.SCRUB_PROBE_PASSWORD = 'secret' process.env.SCRUB_PROBE_PLAIN = 'visible' try { const env = scrubbedParentEnv() expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.dsh_scrub_probe_lower).toBeUndefined() expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined() expect(env.SCRUB_PROBE_PLAIN).toBe('visible') expect(env.PATH).toBeDefined() } finally { delete process.env.DSH_SCRUB_PROBE + delete process.env.dsh_scrub_probe_lower delete process.env.SCRUB_PROBE_TOKEN delete process.env.SCRUB_PROBE_PASSWORD delete process.env.SCRUB_PROBE_PLAIN diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 2bbfe56b2c..842467c764 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -338,16 +338,17 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'bash', title: 'Bash executor seam', mode: 'seam', - implementations: ['bash-local', 'bash-sandbox'], - consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], - note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.', + implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'], + consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude', 'hooks-codex'], + note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.', }, { key: 'bashEnv', - pkg: 'tool-bash', + pkg: 'bash-env', title: 'Managed bash environment registry', mode: 'core', - note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', + consumers: ['tool-bash', 'tool-pwsh'], + note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.', }, { key: 'pty', From 725cd3afd06ffe62b852cd43eade5f9079c6eba3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 22:06:29 +0800 Subject: [PATCH 041/108] docs(catalog): regenerate the cordis services catalog for the scrub JSDoc line shift --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 19384976a2..790e18ce1e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2097,7 +2097,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:91`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` From d6a763020b36e0332686eb02fc7ff3905ecbe235 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 22:08:35 +0800 Subject: [PATCH 042/108] docs(pwsh-local): list the 5.1 non-ASCII stdin gap under Known Limitations (both languages) --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 1 + packages/bash/pwsh-local/README.zh.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 0d11b89d6b..c097bc6e77 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: e061d1bd614fa4aa13d3ae9418c2e4af47d72518 -README.zh.md: 3a8d2b7ea41c02dbaf72273b0e9ef0f2c023068f +README.md: 76a30939d68ac866880f906b928b265628f4de1e +README.zh.md: af98af1b83baa72ad8345362b583b61fa634d29a diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index e061d1bd61..76a30939d6 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -51,5 +51,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. +- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index 3a8d2b7ea4..af98af1b83 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -51,5 +51,6 @@ - **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 +- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。 From b482f12d571f0b11713239dc2d114f5964cc812d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 00:21:17 +0800 Subject: [PATCH 043/108] fix(coverage): probe the pwsh exemption with the executor's own resolution The review caught the pwshCoverageExclusions comment claiming the inverse of the code's behavior: a bare-PATH probe FAILING grants the exemption, so a win32 host where only install-location pwsh (or 5.1) resolves kept the exemption while its suites ran - over-exempting, never tightening. resolvePwshPath/candidatePwshPaths move to the dependency-free pwsh-local/src/resolve.ts (index.ts re-exports; public API unchanged) and vitest.config.ts probes with that shared definition, so the exemption is active exactly when the suites skip. The two spec headers stop saying 'on PATH' for a probe that is deliberately not PATH-only, and the parity note records the abort backport as the one both-ways parity change (both languages, pairing re-recorded); catalogs regenerated for the line shift. --- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 +- .../2026-08-02-pwsh-tool-bash-parity.md | 1 + .../2026-08-02-pwsh-tool-bash-parity.zh.md | 3 +- docs/config-catalog.md | 2 +- packages/bash/pwsh-local/src/index.ts | 53 ++-------------- packages/bash/pwsh-local/src/resolve.ts | 60 +++++++++++++++++++ .../bash/pwsh-local/tests/executor.spec.ts | 4 +- .../bash/tool-pwsh/tests/integration.spec.ts | 2 +- vitest.config.ts | 10 ++-- 9 files changed, 78 insertions(+), 61 deletions(-) create mode 100644 packages/bash/pwsh-local/src/resolve.ts diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 913cf0cc06..6cbc24d8aa 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: fb0f3fff1ed00dfde286781733881facfb1e6c7b -2026-08-02-pwsh-tool-bash-parity.zh.md: 9dc84bcd5e53c52d5d68b2db61cf79fe7fb97bb9 +2026-08-02-pwsh-tool-bash-parity.md: 417c6d6bc91eb3afaa38976e0013e4fbe72854ca +2026-08-02-pwsh-tool-bash-parity.zh.md: 926433526b3820f7b4770acb3ee172448970b601 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index fb0f3fff1e..417c6d6bc9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -27,6 +27,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Consequences - The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. - The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 9dc84bcd5e..926433526b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -26,7 +26,8 @@ Status: implemented ## 后果 -- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的"拿代码 grep 对证"检查通过。 +- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 - pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f14a7a5900..9a2eb8f0be 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1004,7 +1004,7 @@ export interface Config { } ``` -Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts) +Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index b966a579af..316d2c8651 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -13,14 +13,13 @@ * @module @deepseek-ai/dsh-pwsh-local */ -import { existsSync } from 'node:fs' -import { join } from 'node:path' import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolvePwshPath } from './resolve.ts' /* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ /** @@ -77,53 +76,9 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd/pwshPath have none). */ type ResolvedConfig = Required> & Pick -/** - * Well-known Windows PowerShell install locations plus PATH entries, newest - * first. Explicitly parameterized (env) so resolution is a pure function of - * its inputs on every platform. - * @param env - the environment to probe; defaults to the process environment. - * @returns candidate `pwsh` executable paths in resolution order. - */ -export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { - const programFiles = env.ProgramFiles ?? 'C:\\Program Files' - const systemRoot = env.SystemRoot ?? 'C:\\Windows' - const candidates = [ - join(programFiles, 'PowerShell', '7', 'pwsh.exe'), - ] - // Microsoft Store installs (and any user-added location) live on PATH; - // entries may carry surrounding quotes from `setx`-style definitions. - for (const entry of (env.PATH ?? '').split(';')) { - const trimmed = entry.trim().replace(/^"|"$/g, '') - if (trimmed.length === 0) continue - candidates.push(join(trimmed, 'pwsh.exe')) - } - // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. - candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) - return candidates -} - -/** - * Resolve the pwsh executable this executor spawns. - * @param configured - an explicit `pwshPath` config value, trusted as-is. - * @param env - the environment to probe on Windows; defaults to the process environment. - * @param platform - the platform to resolve for; defaults to the process platform. - * @returns the first existing well-known location on Windows (PowerShell 7 - * install, a PATH entry such as the Microsoft Store install, then Windows - * PowerShell 5.1), else `pwsh` for PATH resolution. - */ -export function resolvePwshPath( - configured?: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, -): string { - if (configured !== undefined && configured.length > 0) return configured - if (platform === 'win32') { - for (const candidate of candidatePwshPaths(env)) { - if (existsSync(candidate)) return candidate - } - } - return 'pwsh' -} +// Resolution lives in its own dependency-free module so the repository's +// coverage-gate probe shares the exact definition the suites use. +export { candidatePwshPaths, resolvePwshPath } from './resolve.ts' /** Project a settled collect-mode reader into the final CollectedOutput shape. */ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts new file mode 100644 index 0000000000..c6ded2f883 --- /dev/null +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -0,0 +1,60 @@ +/** + * PowerShell executable resolution, dependency-free so non-package consumers + * (the repository's coverage-gate probe in `vitest.config.ts`) can share the + * ONE resolution definition with the executor and its suites — a probe that + * resolved differently from the code under test could exempt a file whose + * suites actually run. + * + * @module @deepseek-ai/dsh-pwsh-local/resolve + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Well-known Windows PowerShell install locations plus PATH entries, newest + * first. Explicitly parameterized (env) so resolution is a pure function of + * its inputs on every platform. + * @param env - the environment to probe; defaults to the process environment. + * @returns candidate `pwsh` executable paths in resolution order. + */ +export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] { + const programFiles = env.ProgramFiles ?? 'C:\\Program Files' + const systemRoot = env.SystemRoot ?? 'C:\\Windows' + const candidates = [ + join(programFiles, 'PowerShell', '7', 'pwsh.exe'), + ] + // Microsoft Store installs (and any user-added location) live on PATH; + // entries may carry surrounding quotes from `setx`-style definitions. + for (const entry of (env.PATH ?? '').split(';')) { + const trimmed = entry.trim().replace(/^"|"$/g, '') + if (trimmed.length === 0) continue + candidates.push(join(trimmed, 'pwsh.exe')) + } + // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts. + candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + return candidates +} + +/** + * Resolve the pwsh executable this executor spawns. + * @param configured - an explicit `pwshPath` config value, trusted as-is. + * @param env - the environment to probe on Windows; defaults to the process environment. + * @param platform - the platform to resolve for; defaults to the process platform. + * @returns the first existing well-known location on Windows (PowerShell 7 + * install, a PATH entry such as the Microsoft Store install, then Windows + * PowerShell 5.1), else `pwsh` for PATH resolution. + */ +export function resolvePwshPath( + configured?: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (configured !== undefined && configured.length > 0) return configured + if (platform === 'win32') { + for (const candidate of candidatePwshPaths(env)) { + if (existsSync(candidate)) return candidate + } + } + return 'pwsh' +} diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 5113f6a988..4552f2eeec 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -3,8 +3,8 @@ * service plus a REAL pwsh executable, exercised through the executor seam * (`resolve` → `run`/`start`). These verify the world — actual PowerShell * runs, output capture, truncation and spill, deadlines, kill escalation, and - * the background-handle contract. The suite self-skips when no `pwsh` is on - * PATH (a CI accommodation for hosts without PowerShell); the pure unit tests + * the background-handle contract. The suite self-skips when no usable `pwsh` + * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests * (config validation, executable resolution) run on every platform. PowerShell * writes CRLF on Windows, so exact text assertions normalize line endings. */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts index 711f7663b1..c347866f50 100644 --- a/packages/bash/tool-pwsh/tests/integration.spec.ts +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -4,7 +4,7 @@ * process. These verify the world — actual commands run, stdout/stderr come * back, exit codes render, timeouts abort, background tasks settle through the * generic task runtime, and per-session cwd resolution works. The suite - * self-skips when no `pwsh` is on PATH (a CI accommodation for hosts without + * self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage * gate. */ diff --git a/vitest.config.ts b/vitest.config.ts index 458b1134b1..c922cb8a32 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process' import tsconfigPaths from 'vite-tsconfig-paths' +import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' @@ -44,11 +45,10 @@ const windowsCoverageExclusions = process.platform === 'win32' // self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts // green while CI runners ship pwsh and still enforce the full bar. The probe -// is deliberately PATH-only (narrower than the suites' resolvePwshPath): a -// win32 host where only install-location pwsh or 5.1 resolves forfeits the -// exemption while the suites still run, so the gate can only get stricter, -// never falsely green. -const pwshCoverageExclusions = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +// runs the suites' own resolution (the dependency-free resolve.ts module), +// so the exemption is active exactly when the suites skip — a mismatched +// narrower probe could exempt the file on hosts whose suites actually run. +const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 ? [] : ['packages/bash/pwsh-local/src/index.ts'] From f4d243e3001e3e1691e4445b1443759a49ffa112 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 00:31:32 +0800 Subject: [PATCH 044/108] chore(knip): drop the stale pwsh ignoreBinaries entry Every bare spawnSync('pwsh') became resolvePwshPath(), so knip's config hint correctly flags the ignore as unused. --- knip.json | 1 - 1 file changed, 1 deletion(-) diff --git a/knip.json b/knip.json index 3ad4bad366..71ee4faa3c 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,6 @@ ], "ignoreBinaries": [ "bwrap", - "pwsh", "python3", "sandbox-exec", "taskkill" From 6343d8f6e6793d93adb13fca805d52d3cada10f5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 3 Aug 2026 17:31:08 +0800 Subject: [PATCH 045/108] fix(sandbox): evidence-gate runner failures (round 1) --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 19 +-- .../feature/2026-07-06-sandbox.zh.md | 19 +-- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 6 +- docs/core-data-structures/sandbox.md | 33 ++++- docs/core-data-structures/sandbox.zh.md | 33 ++++- ...ice-misclassified-child-failures.i18n.yaml | 6 + ...ial-notice-misclassified-child-failures.md | 55 ++++++++ ...-notice-misclassified-child-failures.zh.md | 55 ++++++++ docs/postmortem/README.i18n.yaml | 4 +- docs/postmortem/README.md | 1 + docs/postmortem/README.zh.md | 1 + .../partial-landlock.cordis.snapshot.yml | 38 ++++++ .../acp-agent/partial-landlock.cordis.yml | 13 ++ examples/acp-agent/tests/acp.snapshot.ts | 13 ++ .../fixtures/partial-landlock-sandbox.ts | 26 ++++ .../partial-landlock-child-failure/input.json | 7 + .../session.jsonl | 26 ++++ .../stdout.expected.jsonl | 4 + packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 4 +- packages/bash/bash-sandbox/README.zh.md | 4 +- packages/bash/bash-sandbox/src/helpers.ts | 39 +++++- packages/bash/bash-sandbox/src/index.ts | 30 +++-- .../tests/partial-landlock.spec.ts | 125 ++++++++++++++++++ .../bash/bash-sandbox/tests/sandbox.spec.ts | 59 +++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 4 +- packages/fs/tool-fs-search/README.zh.md | 4 +- packages/fs/tool-fs-search/package.json | 4 + packages/fs/tool-fs-search/src/search-core.ts | 14 +- .../tool-fs-search/tests/integration.spec.ts | 60 +++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 24 +++- packages/pty/pty-local/tests/index.spec.ts | 4 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 4 +- packages/sandbox/sandbox-local/README.zh.md | 4 +- packages/sandbox/sandbox-local/src/index.ts | 72 +++++++--- .../sandbox/sandbox-local/tests/local.spec.ts | 47 +++++-- packages/sandbox/sandbox/README.i18n.yaml | 4 +- packages/sandbox/sandbox/README.md | 3 +- packages/sandbox/sandbox/README.zh.md | 3 +- packages/sandbox/sandbox/src/index.ts | 23 +++- pnpm-lock.yaml | 12 ++ scripts/type-equiv.manifest.json | 5 + 51 files changed, 805 insertions(+), 140 deletions(-) create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md create mode 100644 examples/acp-agent/partial-landlock.cordis.snapshot.yml create mode 100644 examples/acp-agent/partial-landlock.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl create mode 100644 packages/bash/bash-sandbox/tests/partial-landlock.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index d5727d9c4b..e1c1e00880 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 4355ab57374f77f1733fff39e2fb4ccadcedaf6b -2026-07-06-sandbox.zh.md: 02c5337555b7466c2bac7fc4774cdd2c945178ae +2026-07-06-sandbox.md: 85c7f64d379e4fec18511b3b8566e84edb34c1a2 +2026-07-06-sandbox.zh.md: 0278b6302d2647c49980b10646e3b9158356b3ec diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 4355ab5737..85c7f64d37 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -50,7 +50,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its structured runner-failure evidence (`runnerFailureRules`, optional allowed exit codes plus fatal per-line signatures after exact informational-line exclusions); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -60,9 +60,9 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Outer-shell failures are separate rules scoped to the resolved/configured argv0 and shell exit 126/127. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config and maps those entries into an internal fatal rule, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. -The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. +The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing and print a fatal `landlock-run:` line. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. @@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. A runner failure outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE`; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. Foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, structured runner classification (including partial-Landlock notice-only child outcomes, fatal evidence, outer-shell failures, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. -- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent. +- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. ## Deferred phases @@ -128,12 +128,12 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. -- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus `runnerFailureSignatures` classification carries the safety property instead. +- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. - **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. - **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). @@ -174,6 +174,7 @@ Costs and accepted limits: - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown `SANDBOX_UNAVAILABLE`, the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. +- **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. @@ -184,7 +185,7 @@ Costs and accepted limits: ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. +- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground re-throws structured `SANDBOX_UNAVAILABLE` with the matched fatal line; a background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 02c5337555..0278b6302d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -50,7 +50,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### seam:`ctx.sandbox` -`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串)、以及其 runner 失败方言(`runnerFailureSignatures`,runner 本身失败——因而命令从未运行——时的自我标识方式);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root)以及 `SandboxPolicy`(提供给约束后端的子集)。 +`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串),以及其结构化 runner 失败证据(`runnerFailureRules`,可选的允许退出码加上排除整行精确信息性行后按行匹配的致命签名);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root)以及 `SandboxPolicy`(提供给约束后端的子集)。 策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 @@ -60,9 +60,9 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败签名,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。`runnerCommand` 作为运维人员对 bwrap 形状 runner 的断言跳过选择,但缺失或不可执行的命令仍被归类为沙箱失败,绝不无约束地运行负载。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。外层 shell 失败使用独立规则,并限定为解析后或配置的 argv0 与 shell 退出码 126/127。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,并将这些条目映射到一条内部致命规则;但命令缺失或不可执行时,仍归类为沙箱失败,绝不无约束地运行负载。 -launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec。 +launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec,并打印一行致命的 `landlock-run:` 诊断。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测和 CLI flag,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 @@ -70,7 +70,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。Runner 失败优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 @@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及运行时上下文排序与具体化。 +- **单元测试:** 固定平台选择和 profile、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、致命证据、外层 shell 失败,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 -- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 +- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 ## 延迟阶段 @@ -128,12 +128,12 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 -- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加 `runnerFailureSignatures` 分类承载了安全属性。 +- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 - **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 - **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 @@ -174,6 +174,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 +- **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 - **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 @@ -184,7 +185,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** Runner 失败在分类中优先于拒绝:匹配包装的 `runnerFailureSignatures` 的失败运行意味着命令从未运行——前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带 runner 的 stderr 行,后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。损坏的沙箱永远不会被读作失败的命令,且命令永远不会无约束运行。 +- **如何区分损坏的沙箱与失败的命令?** 只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行;后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc85531d4d..9fca775cc2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -235,7 +235,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) -Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:34`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-cli-demo` @@ -1037,7 +1037,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:19`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf18c6f73c..bab2162821 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1095,7 +1095,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index f8189f4e15..0949bb2279 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -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 -sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 +# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md +sandbox.md: 8483104e0aab78779acb52b232a205280c93cd2b +sandbox.zh.md: b283095f1c847a4b2fca5a109e7894d6e3479839 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..8483104e0a 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## Wrapped argv and classification dialects -`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. +`RunnerFailureRule` combines evidence that a runner failed before executing the command. A consumer requires a nonzero exit, the optional allowed-exit-code gate, and a case-insensitive fatal signature within one remaining stderr line. Case-insensitive exact full-line informational exclusions are removed first, so a benign runner notice cannot prove failure by itself. The matched line remains available as error detail; classification does not rewrite stderr. + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr classifiers. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureRules` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -An operator-configured local runner must supply at least one `runnerFailureSignatures` entry for its own pre-exec refusal dialect; the provider adds outer-shell missing and unexecutable forms automatically. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. +The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one entry for its own pre-exec refusal dialect. The provider maps those entries into one rule and adds a separate argv0-scoped rule for outer-shell missing or unexecutable failures. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case its failure signature carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case a matching structured rule carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 9a52f12675..b283095f1c 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## 包装后的 argv 与分类方言 -`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 方言。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 用于识别沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 +`RunnerFailureRule` 汇集用于判定 runner 在执行命令前失败的证据。消费方要求进程以非零状态退出,并同时满足可选的允许退出码门控,以及余下某一 stderr 行中不区分大小写的致命签名。系统会先按不区分大小写的整行精确匹配移除信息性排除项,因此无害的 runner 通知本身不能证明失败。匹配到的行仍可用作错误详情;分类过程不会重写 stderr。 + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 分类器。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureRules` 用于识别沙箱 runner 在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 +面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一条条目。提供方会将这些条目映射到一条规则,并另加一条按 argv0 限定的外层 shell 规则,用于识别命令缺失或不可执行。这使得可执行的自定义 runner 拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的 runner 也可能在执行时 fail-closed,此时匹配到的结构化规则承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml new file mode 100644 index 0000000000..b11b34b8b7 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml @@ -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 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md +0004-landlock-partial-notice-misclassified-child-failures.md: ccedba6094ffec9de95fbd52d58add4a0e6e5105 +0004-landlock-partial-notice-misclassified-child-failures.zh.md: 89d85d835d58753721927be85a5581b12a490fcf diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md new file mode 100644 index 0000000000..ccedba6094 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md @@ -0,0 +1,55 @@ +# Post-mortem 0004: Landlock partial-enforcement notice misclassified child failures + +English | [中文](0004-landlock-partial-notice-misclassified-child-failures.zh.md) + +Status: resolved + +## Executive summary + +On kernels with an older Landlock ABI, the launcher prints a benign partial-enforcement notice before executing every child. The harness treated that shared `landlock-run:` prefix plus any nonzero child exit as launcher failure, so ordinary outcomes such as ripgrep's exit 1 for no matches surfaced as `SANDBOX_UNAVAILABLE`; filesystem search then hid that structured error behind `SEARCH_FAILED`. Broad signature rules and missing partial-ABI composition coverage let the defect through. Runner classification now requires status-gated fatal evidence after exact informational exclusions, search preserves structured executor errors, and an assembled keyless scenario pins the affected product path. + +## Summary + +The native launcher contract distinguishes two kinds of stderr lines. A partially enforcing kernel prints exactly `landlock-run: partial enforcement (older Landlock ABI)` and continues into the child. A launcher failure prints another `landlock-run:` line and exits 125 without executing the child. + +The harness represented both with one case-insensitive `landlock-run: ` substring. Its consumer classified any nonzero exit carrying that substring as runner failure. The child's status was therefore attached to the launcher's informational line: `false`, ripgrep's no-match exit 1, invalid-pattern exit 2, and even a child-selected exit 125 could be blamed on the sandbox despite successful confinement and execution. + +Filesystem search added a second attribution error. `runRipgrep()` caught every rejected bash run that was not aborted and replaced it with a generic cwd/shell-start `SEARCH_FAILED`, including the structured `SandboxUnavailableError` produced by the sandbox executor. + +## Impact + +On partial-ABI Landlock hosts, legitimate nonzero child outcomes could appear as sandbox infrastructure failure. `glob` and `grep` were especially visible because ripgrep uses exit 1 as successful empty search. When a real sandbox failure did occur through filesystem search, callers lost its `SANDBOX_UNAVAILABLE` code and received an incorrect startup diagnosis. + +The defect did not weaken confinement or run a command unconfined. Its security effect was availability and diagnostic integrity: a valid confined result was rejected or mislabeled. + +## Timeline + +- The native launcher contract defined exit 125 for launcher failures, a fatal `landlock-run:` line for every such failure, and the exact partial-enforcement notice for successful child execution. +- The sandbox provider reduced that contract to `runnerFailureSignatures: ['landlock-run: ']`; the bash consumer combined the prefix with any nonzero exit and reported stderr's first line. +- Unit tests covered clean success, denial diagnostics, and fatal runner prefixes. Real-runner tests self-skipped without a usable kernel and did not force partial enforcement followed by a nonzero child. +- A minimal POSIX wrapper that prints the notice and `exec`s its payload reproduced the failure with `false` and ripgrep no-match. +- Structured rules, shared foreground/background classification, search error preservation, and assembled replay coverage closed the two attribution gaps. + +## Root cause + +The public sandbox result type could express only a bag of substrings. It could not state that Landlock failure requires exit 125, that evidence must occur within one fatal line, or that one exact line under the same prefix is informational. The boolean consumer consequently joined unrelated facts from different processes and selected the first stderr line for detail even when a later line was the fatal evidence. + +The test matrix mirrored that representation. Fake providers emitted either no runner line or an unambiguously fatal prefix; they never emitted a benign runner line before a child-controlled nonzero exit. Real Landlock coverage depended on the host ABI, so full-ABI hosts could not exercise the notice. Filesystem-search tests modeled raw spawn errors but not a structured error thrown by the real sandboxed bash composition. + +Stderr remains an in-band attribution channel. A confined child can deliberately reproduce a runner's gated fatal line and exit status, causing an availability/diagnostic false attribution. The tighter conjunction prevents the accidental collision in this incident but does not authenticate the writer; an out-of-band status protocol remains separate hardening, not a sandbox-bypass fix. + +## Guardrails added + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) carries optional allowed exit codes, case-insensitive per-line fatal signatures, and case-insensitive exact informational-line exclusions. +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line, keeps bwrap/Seatbelt/custom behavior, and separates argv0-scoped outer-shell failures using exit 126/127. +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) uses one evidence-returning classifier for foreground and background execution. Fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr. +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) retains abort as `SEARCH_ABORTED`, propagates existing `HarnessError` instances unchanged, and applies `SEARCH_FAILED` only to untyped start failures. Ripgrep still owns exit 0/1/other semantics inside the adapter. +- Deterministic tests use a POSIX fake partial-Landlock launcher to cover `true`, `false`, child exit 125, permission denial, real fatal diagnostics, and foreground/background parity. A real search composition covers empty grep/glob, invalid regex, and `SANDBOX_UNAVAILABLE` propagation. +- The `examples/acp-agent` keyless snapshot runs direct bash `false` through a test-only partial-Landlock provider, keeping the product regression pinned independently of filesystem-search implementation choices. + +## Lessons + +- Process attribution requires a conjunction of independent evidence; a shared prefix is not a protocol. +- Informational and fatal diagnostics can share a namespace, so exclusions must be exact and narrow while unknown fatal lines stay fail-closed. +- An adapter must preserve structured failures owned by the seam below it instead of replacing them with its own nearest generic category. +- Platform-dependent behavior needs a deterministic fake at the native boundary plus one assembled product path; a self-skipping real-kernel test cannot carry that regression alone. diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md new file mode 100644 index 0000000000..89d85d835d --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md @@ -0,0 +1,55 @@ +# 事故复盘(postmortem) 0004:Landlock 部分强制执行通知导致子进程失败被误归类 + +[English](0004-landlock-partial-notice-misclassified-child-failures.md) | 中文 + +Status: resolved + +## 摘要 + +在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;文件系统搜索随后又用 `SEARCH_FAILED` 遮蔽了这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据;搜索会保留结构化执行器错误,并由一个组装后的无密钥场景固定受影响的产品路径。 + +## 概述 + +原生 launcher 契约区分两类 stderr 行。内核只能部分强制执行时,会精确打印 `landlock-run: partial enforcement (older Landlock ABI)`,然后继续执行子进程。launcher 失败则打印另一行 `landlock-run:` 诊断,在不执行子进程的情况下以 125 退出。 + +harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情况。消费方只要发现非零退出同时携带该子串,就会归类为 runner 失败。因此,子进程的退出状态被错误地关联到 launcher 的信息性行:`false`、ripgrep 无匹配时的退出码 1、无效 pattern 的退出码 2,乃至由子进程自行选择的退出码 125,都可能在约束与执行均成功的情况下被错误归因为沙箱故障。 + +文件系统搜索又造成第二处归因错误。`runRipgrep()` 会捕获 bash 执行器除中止外抛出的所有错误,并将其替换为关于 cwd 或 shell 启动的通用 `SEARCH_FAILED`,其中也包括沙箱执行器产生的结构化 `SandboxUnavailableError`。 + +## 影响 + +在 Landlock ABI 只能部分强制执行的主机上,合法的非零子进程结果可能表现为沙箱基础设施故障。`glob` 和 `grep` 尤其容易暴露该问题,因为 ripgrep 把退出码 1 用作成功的空搜索。当文件系统搜索中确实发生沙箱故障时,调用方也会丢失其 `SANDBOX_UNAVAILABLE` 错误码,转而收到错误的启动诊断。 + +该缺陷没有削弱约束,也没有让命令在无约束状态下运行。其安全影响在于可用性与诊断完整性:有效的受限结果会被拒绝或错误标记。 + +## 时间线 + +- 原生 launcher 契约规定:launcher 失败使用退出码 125,每次此类失败都会打印一行致命的 `landlock-run:` 诊断;成功执行子进程时则打印精确的部分强制执行通知。 +- 沙箱提供方把该契约简化为 `runnerFailureSignatures: ['landlock-run: ']`;bash 消费方将此前缀与任意非零退出组合,并报告 stderr 的第一行。 +- 单元测试覆盖了无诊断的成功、拒绝诊断和致命 runner 前缀。真实 runner 测试在没有可用内核时会自行跳过,也没有强制构造「部分强制执行通知后跟非零子进程退出」的情况。 +- 一个最小 POSIX 包装脚本会打印该通知并 `exec` 其负载;它通过 `false` 与 ripgrep 无匹配场景复现了故障。 +- 结构化规则、前台与后台共享的分类逻辑、搜索错误保留和组装后的回放覆盖共同弥补了这两处归因缺口。 + +## 根因 + +公开的沙箱结果类型只能表达一组子字符串。它无法表示 Landlock 失败必须使用退出码 125、证据必须出现在一行致命诊断内,或同一前缀下有一行精确文本属于信息性通知。消费方的布尔判定逻辑因此把来自不同进程且互不相关的事实组合在一起;即便致命证据位于后续行,它仍选用 stderr 的第一行作为详细信息。 + +测试矩阵与这种表示方式一致。模拟提供方要么不输出 runner 行,要么输出含义明确的致命前缀,从不在由子进程控制的非零退出前输出无害 runner 行。真实 Landlock 覆盖依赖主机 ABI,因此使用完整 ABI 的主机无法覆盖该通知。文件系统搜索测试模拟了原始 spawn 错误,却没有覆盖真实沙箱化 bash 组合抛出的结构化错误。 + +stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的门控致命诊断行与退出状态,造成可用性或诊断误归因。更严格的多项证据合取可以避免本次事故中的意外冲突,但无法验证写入者身份;带外状态协议仍属于独立的加固工作,而非沙箱绕过修复。 + +## 已添加的防护措施 + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。 +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,保留 bwrap/Seatbelt/自定义 runner 的行为,并以退出码 126/127 单独处理按 argv0 限定的外层 shell 失败。 +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 为前台与后台执行使用同一个返回证据的分类器。致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。 +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 把中止保留为 `SEARCH_ABORTED`,原样传播既有 `HarnessError` 实例,并且仅将无类型的启动失败归为 `SEARCH_FAILED`。适配器内部仍按 ripgrep 自身的退出码 0/1/其他语义处理。 +- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖 `true`、`false`、子进程退出码 125、权限拒绝、真实致命诊断,以及前台/后台一致性。真实搜索组合覆盖空 grep/glob、无效正则表达式和 `SANDBOX_UNAVAILABLE` 传播。 +- `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归。 + +## 教训 + +- 进程归因需要多项独立证据同时成立;共享前缀不是协议。 +- 信息性诊断与致命诊断可以共享同一命名空间,因此排除规则必须精确且范围狭窄,同时对未知的致命行保持失败关闭。 +- 适配器必须保留下层 seam 所拥有的结构化失败,而不能用自身最接近的通用类别将其替换。 +- 平台相关行为需要在原生边界放置确定性的模拟实现,并覆盖一条组装后的产品路径;会自行跳过的真实内核测试无法独自固定该回归。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e027911357..c706a54f4a 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -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 docs/postmortem/README.md -README.md: 4858f8841e92a895f2d1a840b59b42758e83d952 -README.zh.md: 127eb19422f1eb6738c1d246791064096f4382f9 +README.md: ffde0057304856b7c7718e3dd1f4743c48ee193f +README.zh.md: cf8cfef52bf006619cf31087756fc8b3555b707d diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index 4858f8841e..ffde005730 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -15,3 +15,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | | [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock partial-enforcement notice misclassified child failures | diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 127eb19422..cf8cfef52b 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -15,3 +15,4 @@ | [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | | [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock 部分强制执行通知导致子进程失败被误归类 | diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml new file mode 100644 index 0000000000..685f47d814 --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless partial-Landlock composition: replay the authored model turns and +# replace the shipping provider with a deterministic older-ABI stand-in. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml new file mode 100644 index 0000000000..41725299a8 --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -0,0 +1,13 @@ +# Live counterpart for the partial-Landlock snapshot overlay. It replaces only +# the sandbox provider; the authored scenario is skipped in record mode. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - insert: + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 166e0cf0fe..e837af910b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -47,6 +47,7 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -131,6 +132,18 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // Authored keyless replay through a test-only partial-Landlock provider: + // the exact compatibility notice must stay ordinary stderr when the wrapped + // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. + { + name: 'partial-landlock-child-failure', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { DSH_PERMISSION_MODE: 'read-only' }, + posixOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts new file mode 100644 index 0000000000..2ce25885f0 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -0,0 +1,26 @@ +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' + +/** Snapshot-only provider that reproduces an older-ABI Landlock launch. */ +export default class PartialLandlockSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { + argv: [ + 'bash', + '-c', + `printf '%s\\n' '${NOTICE}' >&2; exec "$@"`, + 'partial-landlock-run', + ...argv, + ], + enforcement: 'partial', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: [NOTICE], + }], + } + } +} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json new file mode 100644 index 0000000000..57f5effa73 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl new file mode 100644 index 0000000000..d731ba4811 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1785218500000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785218500001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785218500002,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"44444444-4444-4444-8444-444444444445"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785218500003,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":1785218500004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"44444444-4444-4444-8444-444444444446"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785218500005,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785218500006,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785218500007,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1785218500008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1785218500009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"partial-landlock-call","name":"bash","argumentsDelta":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1785218500010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785218500011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":11,"time":1785218500012,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785218500013,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"44444444-4444-4444-8444-444444444447"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785218500014,"data":{"turn":1,"step":1,"callId":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}} +{"type":"tool/result","seq":14,"time":1785218500015,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"partial-landlock-call"},"content":[{"type":"tool-result","toolCallId":"partial-landlock-call","content":[{"type":"text","text":"[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"}],"isError":false}],"role":"user","id":"44444444-4444-4444-8444-444444444448"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785218500016,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785218500017,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1785218500018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":18,"time":1785218500019,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_EXIT_PRESERVED"}}} +{"type":"assistant/chunk","seq":19,"time":1785218500020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"type":"assistant/chunk","seq":20,"time":1785218500021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":21,"time":1785218500022,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1785218500023,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_EXIT_PRESERVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"44444444-4444-4444-8444-444444444449"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785218500024,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1785218500025,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl new file mode 100644 index 0000000000..98a85f5207 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index 640eb1810f..e1f518e8f1 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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: 035a8ad2401ca608d264049d454359eda7b2b9a7 -README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb +README.md: 851bedea3589efe164cf49b7e6db1a5f3d64df24 +README.zh.md: f4eb13dd11f8a22dd0a0d594a615567571b1ccde diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 035a8ad240..851bedea35 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -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 failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. +- **Runner failures are sandbox failures, never command failures.** Foreground and background execution use the same structured classifier: 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`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. - **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/). @@ -72,7 +72,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). For an execution-time runner failure, this backend supplies the first stderr line as its detail. +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). For an execution-time runner failure, this backend supplies the matched fatal stderr line as its detail and preserves the original stderr collection. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index cee27a9baa..f4eb13dd11 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台与后台执行使用同一个结构化分类器:先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 - **部署回退,每次调用策略。** [`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/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index a98f47216e..a58354c40d 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -5,6 +5,13 @@ */ import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} /** * Quote one string as a single-quoted POSIX shell word. @@ -26,13 +33,33 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin } /** - * Classify a failed run against the selected backend's runner-failure dialect. - * @param result - settled foreground run. - * @param signatures - case-insensitive runner-failure substrings from the active wrap. - * @returns whether the failed run matches that runner-failure dialect. + * 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(result: BashRunResult, signatures: readonly string[]): boolean { - return matchesSignature(result.exitCode, result.stderr.text, signatures) +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())) + const fatalSignatures = rule.fatalSignatures.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 } /** diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index f46bad4006..854789f428 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -11,7 +11,14 @@ import { Context } from 'cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' @@ -51,7 +58,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { mode: ConfinedSandboxMode enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] }>() constructor(ctx: Context, config: Config) { @@ -84,10 +91,11 @@ export class SandboxBashExecutor extends LocalBashExecutor { } const confined = this.confine(spec.command, { ...policy, mode }) const result = await super.run({ ...spec, command: confined.command }) - // Runner failure outranks denial because the command did not run. Throw the - // same fail-closed error as confine-time discovery with the first stderr line. - if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) { - throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0]) + // 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 } } } @@ -99,8 +107,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { // Install facts synchronously; promise settlement cannot run before start() returns. const confined = this.confine(spec.command, { ...policy, mode }) const proc = super.start({ ...spec, command: confined.command }) - const { enforcement, denialSignatures, runnerFailureSignatures } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules }) return proc } @@ -113,7 +121,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { if (facts !== undefined) { this.processFacts.delete(proc) // Runner failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures) + const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), @@ -136,14 +144,14 @@ export class SandboxBashExecutor extends LocalBashExecutor { command: string enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } { const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) return { command: `exec ${confined.argv.map(shellQuote).join(' ')}`, enforcement: confined.enforcement, denialSignatures: confined.denialSignatures, - runnerFailureSignatures: confined.runnerFailureSignatures, + runnerFailureRules: confined.runnerFailureRules, } } } diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts new file mode 100644 index 0000000000..9554533598 --- /dev/null +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -0,0 +1,125 @@ +/** + * Deterministic composition proof for the partial-Landlock diagnostic: the + * real local provider and sandbox bash executor wrap commands through a POSIX + * fake launcher that prints the native informational line before exec. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const FATAL = 'landlock-run: landlock ruleset error: Invalid argument' + +const contexts: Context[] = [] +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** Write a fake native launcher that reports partial enforcement, then execs or fails. */ +async function fakeLauncher(fatal: boolean): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-')) + tempDirs.push(dir) + const launcher = join(dir, 'landlock-run') + const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : '' + await writeFile(launcher, `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro|--rw) shift 2 ;; + --) shift; break ;; + *) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;; + esac +done +printf '%s\\n' '${NOTICE}' >&2 +${fatalBranch}exec "$@" +`, { mode: 0o755 }) + return launcher +} + +async function setup(fatal = false): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { + platform: 'linux', + probeBwrap: () => false, + probeLandlock: () => 'partial', + landlockLauncher: await fakeLauncher(fatal), + } + await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) + return ctx.bash as SandboxBashExecutor +} + +describe('partial Landlock runner-failure classification', () => { + it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => { + const bash = await setup() + for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) { + const result = await bash.run(bash.resolve({ command })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${NOTICE}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + } + }) + + it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => { + const bash = await setup(true) + const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`Runner failure: ${FATAL}`) + expect((error as Error).message).not.toContain(NOTICE) + }) + + it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + }) + + it('applies the same evidence rule to notice-only background exits', async () => { + const bash = await setup() + for (const command of ['false', 'exit 125']) { + const task = bash.start(bash.resolve({ command })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + } + }) + + it('classifies a background notice plus child Permission denied as denial', async () => { + const bash = await setup() + const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + }) + + it('makes a background fatal line outrank denial text after the notice', async () => { + const bash = await setup(true) + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'partial', + runnerFailed: true, + }) + const output = task.readOutput().delta + expect(output).toContain(NOTICE) + expect(output).toContain(FATAL) + }) +}) diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 90a67999c8..03dede1303 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -30,12 +30,12 @@ interface ConfineCall { /** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const -/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ -const RUNNER_FAILURE = ['fake-runner: '] as const +/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ +const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const /** 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: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }) + ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }) /** * Boot a context with a recording fake `ctx.sandbox` (behavior injectable @@ -93,7 +93,7 @@ describe('the provider hand-off', () => { it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => { // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix: // the sentinel only prints if the executor spawned the WRAPPED argv. - const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' })) expect(result.stdout.text).toBe('1') expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) @@ -234,10 +234,47 @@ describe('classifyDenial', () => { }) describe('classifyRunnerFailure', () => { - it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => { - const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'] - expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true) - expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true) + it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => { + const rules = [{ + allowedExitCodes: [126, 127], + fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'], + }] + expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail) + .toBe('bash: /Opt/Runners/bwrap: No such file or directory') + expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail) + .toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND') + expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined() + }) + + it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }] + expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined() + expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules)) + .toEqual({ detail: `${notice}: extra detail` }) + expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules)) + .toEqual({ detail: 'landlock-run: exec failed: No such file or directory' }) + }) + + it.each([ + 'landlock-run: usage error: missing `-- ...` command', + 'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)', + 'landlock-run: cannot open rule path: /gone: No such file or directory', + 'landlock-run: landlock ruleset error: Invalid argument', + 'landlock-run: exec failed: Permission denied', + 'landlock-run: out of memory', + 'landlock-run: future fatal diagnostic', + ])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => { + const rules = [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }] + expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal }) }) }) @@ -253,7 +290,7 @@ describe('result facts', () => { }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { - const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) @@ -284,7 +321,7 @@ describe('background sandbox facts', () => { it('a foreground runner failure throws the fail-closed error, never a task result', async () => { // The wrap's runner prefix on a failed run means the SANDBOX broke and // the command never ran — the late twin of the confine-time throw, with - // the runner's own first stderr line carried as the cause. + // the matched fatal stderr line carried as the cause. const { bash } = await setup() const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' })) await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) @@ -315,7 +352,7 @@ describe('background sandbox facts', () => { let call = 0 const { bash } = await setup({}, (argv) => { const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick - return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE } + return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE } }) const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' })) const quick = bash.start(bash.resolve({ command: 'true' })) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7091f95447..8252aded19 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1801,7 +1801,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConfinedArgv', - declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}', + declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', }, { name: 'ConfinedSandboxMode', @@ -2347,6 +2347,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, + { + name: 'RunnerFailureRule', + declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index bedb8289c1..8000387878 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -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/fs/tool-fs-search/README.md -README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4 -README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60 +README.md: 93df279e958c569cd0b30618d3c3e80f773d452b +README.zh.md: 5cec79577dc896c2a95f9539a9911adcdc23cfe1 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index b12ffda986..93df279e95 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -46,7 +46,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax ## Errors -Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. +Search-owned failures carry `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). An existing structured `HarnessError` rejected by the bash executor, including `SANDBOX_UNAVAILABLE`, propagates unchanged; only an untyped spawn, cwd, or shell-start rejection becomes `SEARCH_FAILED`, while an aborted signal remains `SEARCH_ABORTED`. ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. ## Model Experience @@ -114,7 +114,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. +Search-owned failures render as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata; structured bash-executor failures retain their owning name and code. #### Token effect diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 7dd6cdf9a2..5cec79577d 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -46,7 +46,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 错误 -搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 +搜索层自身的失败携带 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。bash 执行器拒绝并返回的既有结构化 `HarnessError`(包括 `SANDBOX_UNAVAILABLE`)会原样传播;只有无类型的 spawn、cwd 或 shell 启动拒绝会转换为 `SEARCH_FAILED`,中止信号仍为 `SEARCH_ABORTED`。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 ## 模型体验 @@ -114,7 +114,7 @@ glob 描述会说明配置所指定的超限结果排序方式。已生成的 [` #### 模型看到的内容 -失败会规范化为 `Error: `,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。 +搜索层自身的失败会渲染为 `Error: `,并附带结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据;来自 bash 执行器的结构化失败则保留其原有名称和错误码。 #### Token 影响 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index bf9cf15aa0..1ee39d93ab 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,10 +44,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 402fc9d655..eb29963ef0 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -152,10 +152,9 @@ function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxByt * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / * `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's - * infrastructure failures (pre-aborted signal, unusable workdir, missing - * shell) — is translated into the same taxonomy: a pre-aborted signal becomes - * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as - * `cause`. + * infrastructure failures becomes `SEARCH_ABORTED` when the forwarded signal + * aborted, propagates an existing structured {@link HarnessError} unchanged, + * and wraps only untyped spawn/workdir/shell-start errors as `SEARCH_FAILED`. * * @param ctx - the plugin context; execution uses its `bash` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. @@ -182,12 +181,13 @@ export async function runRipgrep( try { result = await ctx.bash.run(spec) } catch (error: unknown) { - // The seam contract: run() REJECTS only for infrastructure failures — a - // pre-aborted signal, an unusable workdir, a missing shell. Translate them - // so these failures stay machine-routable under the SEARCH_* taxonomy. + // Abort owns the outcome even when the executor rejects during teardown. if (spec.signal?.aborted === true) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) } + // Infrastructure implementations may already provide a stable harness + // error (notably SANDBOX_UNAVAILABLE); preserve that owning taxonomy. + if (error instanceof HarnessError) throw error throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) } if (result.aborted) { diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 8cb96e7e66..3d6ef25761 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,6 +18,10 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -68,6 +72,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) afterEach(async () => { + await ctx.fiber.dispose() await rm(dir, { recursive: true, force: true }) }) @@ -192,4 +197,59 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () expect(text(result)).toContain('could not start') }) }) + + it('preserves rg semantics through partial Landlock and propagates a real structured sandbox failure', async () => { + await ctx.fiber.dispose() + const launcher = join(dir, 'landlock-run') + const failMarker = join(dir, 'fail-runner') + await writeFile(launcher, `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro|--rw) shift 2 ;; + --) shift; break ;; + *) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;; + esac +done +printf '%s\\n' 'landlock-run: partial enforcement (older Landlock ABI)' >&2 +if [ -e ${ToolFsSearch.singleQuote(failMarker)} ]; then + printf '%s\\n' 'landlock-run: landlock ruleset error: fixture failure' >&2 + exit 125 +fi +exec "$@" +`, { mode: 0o755 }) + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSandboxProvider, {}) + ;(ctx.sandbox as LocalSandboxProvider).internals = { + platform: 'linux', + probeBwrap: () => false, + probeLandlock: () => 'partial', + landlockLauncher: launcher, + } + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: dir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: dir, timeoutMs: 20_000 }) + await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) + + const grepNoMatch = await call('grep', { pattern: 'does-not-exist' }) + expect(grepNoMatch.isError).toBe(false) + expect(text(grepNoMatch)).toBe('No matches found') + + const globNoFiles = await call('glob', { pattern: '*.does-not-exist' }) + expect(globNoFiles.isError).toBe(false) + expect(text(globNoFiles)).toBe('No files found') + + const invalidRegex = await call('grep', { pattern: '(unclosed' }) + expect(invalidRegex.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) + + await writeFile(failMarker, '') + const sandboxFailure = await call('grep', { pattern: 'alpha' }) + expect(sandboxFailure.error).toMatchObject({ + info: { name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }, + }) + expect(text(sandboxFailure)).toContain('Runner failure: landlock-run: landlock ruleset error: fixture failure') + expect(text(sandboxFailure)).not.toContain('could not start its search command') + }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 389d8dbe05..3b9b5ad6fe 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { join, sep } from 'node:path' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' +import { CallId, HarnessError, createUserMessage } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' @@ -373,7 +373,7 @@ describe('workdir derivation and signal forwarding', () => { const controller = new AbortController() bash.handler = () => { controller.abort('cancel search') - throw new Error('executor stopped on abort') + throw new HarnessError('structured executor failure during abort', 'SANDBOX_UNAVAILABLE') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) @@ -403,6 +403,26 @@ describe('workdir derivation and signal forwarding', () => { expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) + + it('propagates an existing structured bash failure unchanged', async () => { + const { ctx, bash } = await setup() + const upstream = new HarnessError('sandbox runner failed', 'SANDBOX_UNAVAILABLE') + bash.handler = () => { throw upstream } + + const rejection = ToolFsSearch.runRipgrep( + ctx, + { signal: testToolSignal } as never, + 'grep', + "rg --json --regexp='x'", + 20_000_000, + ) + await expect(rejection).rejects.toBe(upstream) + + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.error).toMatchObject({ info: { name: 'HarnessError', code: 'SANDBOX_UNAVAILABLE' } }) + expect(text(result)).toContain('sandbox runner failed') + expect(text(result)).not.toContain('could not start') + }) }) describe('exit semantics and failure classification', () => { diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d9d8aa16d1..abacdaaf9f 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -16,7 +16,7 @@ import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' class EmptySandbox extends SandboxProvider { confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } @@ -25,7 +25,7 @@ class RecordingSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6fa7804980..52d832a7fd 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -26,7 +26,7 @@ class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b1bc368ab2..15748147de 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -31,7 +31,7 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d6b4a08968..35a4eb5739 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -31,7 +31,7 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 1bcf308a49..2f3b8d24d0 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -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/sandbox/sandbox-local/README.md -README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3 -README.zh.md: 5c02b0ce3fac1e39a16f8fc32517856996c30e23 +README.md: d53f138a9a752a6ea62992f269bf76680a24d49f +README.zh.md: b0f599af04f101712029585677b7a25fc11e3908 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 923d983be8..d53f138a9a 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -6,9 +6,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal. -Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. -Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. +Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Separate outer-shell rules use the resolved/configured argv0 with missing or unexecutable wording and exit 126/127. `runnerCommand` remains the operator-facing config key for a custom runner's fatal signatures and skips probes. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 5c02b0ce3f..b0f599af04 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -6,9 +6,9 @@ 包(package)根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。 -不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 -策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形式 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会被拒绝。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。独立的外层 shell 规则使用解析后或配置的 argv0、缺失或不可执行诊断文本,以及退出码 126/127。`runnerCommand` 仍是面向运维人员的配置键,用于指定自定义 runner 的致命签名,并会跳过探测。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 98dc86d23e..f8ccdc0c9d 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,18 +1,23 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr dialects. Missing or unusable + * reports each wrap's enforcement and stderr classification facts. Missing or unusable * confinement fails closed rather than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run' +import { + LAUNCHER_BIN, + LAUNCHER_FAILURE_EXIT, + launcherPath as landlockLauncherPath, + probe as defaultProbeLandlock, +} from 'node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -142,15 +147,36 @@ const DENIAL_SIGNATURES = { } as const satisfies Record /** - * Runner-owned stderr prefixes cover both internal refusal and shell-level - * not-found errors. Consumers match these before denial text because the - * command never ran on this path. + * Runner-owned fatal diagnostics. Landlock's launcher contract reserves exit + * 125 for launcher failure and emits the partial-ABI notice before successful + * child execution, so both the status gate and the exact exclusion are needed. */ -const RUNNER_FAILURE_SIGNATURES = { - bwrap: ['bwrap: '], - landlock: [`${LAUNCHER_BIN}: `], - seatbelt: ['sandbox-exec: '], -} as const satisfies Record +const RUNNER_FAILURE_RULES = { + bwrap: [{ fatalSignatures: ['bwrap: '] }], + landlock: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: [`${LAUNCHER_BIN}: `], + informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], + }], + seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], +} as const satisfies Record + +/** + * Failure shapes emitted by the outer `bash -c 'exec ...'` before the runner + * starts. Shells vary between 126 and 127 for a missing path containing `/`, + * but keep the configured/resolved argv0 and missing/unexecutable wording. + */ +function outerShellFailureRules(argv0: string): readonly RunnerFailureRule[] { + return [{ + allowedExitCodes: [126, 127], + fatalSignatures: [ + `exec: ${argv0}: not found`, + `${argv0}: No such file or directory`, + `${argv0}: Permission denied`, + `exec: ${argv0}: cannot execute`, + ], + }] +} /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless @@ -204,7 +230,7 @@ export class LocalSandboxProvider extends SandboxProvider { * @param argv - the exact argv the caller is about to spawn. * @param policy - the file-effect policy this execution runs under. * @returns the wrapped argv plus the selected backend's enforcement completeness, denial - * signatures, and runner-failure signatures; throws the fail-closed + * signatures, and structured runner-failure rules; throws the fail-closed * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner. */ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { @@ -214,23 +240,25 @@ export class LocalSandboxProvider extends SandboxProvider { argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv], enforcement: 'full', denialSignatures: DENIAL_SIGNATURES.runnerCommand, - // The operator names the configured runner's own pre-exec refusal dialect; the consumer - // additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the - // missing/unexecutable outer-shell shapes ourselves. - runnerFailureSignatures: [ - ...this.configuredRunnerFailureSignatures, - `exec: ${argv0}: not found`, - `${argv0}: No such file or directory`, - `${argv0}: Permission denied`, + // Preserve the operator-facing signature config as one internal rule; + // outer-shell launch failures remain a separate, argv0-scoped rule. + runnerFailureRules: [ + ...outerShellFailureRules(argv0), + { fatalSignatures: this.configuredRunnerFailureSignatures }, ], } } const selected = this.selectRunner(policy.mode) + const runnerArgv = this.runnerArgv(selected.runner, policy) + const argv0 = runnerArgv[0] as string return { - argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], + argv: [...runnerArgv, '--', ...argv], enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], - runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], + runnerFailureRules: [ + ...outerShellFailureRules(argv0), + ...RUNNER_FAILURE_RULES[selected.runner], + ], } } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f7cc952498..94078facc6 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -23,6 +23,19 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } +/** Expected argv0-scoped rule for failures in bash's outer `exec`. */ +function outerShellRule(argv0: string) { + return { + allowedExitCodes: [126, 127], + fatalSignatures: [ + `exec: ${argv0}: not found`, + `${argv0}: No such file or directory`, + `${argv0}: Permission denied`, + `exec: ${argv0}: cannot execute`, + ], + } +} + async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) { const ctx = new Context() await ctx.plugin(LocalSandboxProvider, config) @@ -115,11 +128,9 @@ describe('runnerCommand config', () => { // wrap through an outer `bash -c 'exec …'` — a missing or // unexecutable runner fails with the OUTER shell's argv0-scoped // shapes, and those classify as sandbox failures like any rung. - runnerFailureSignatures: [ - 'fake-runner: profile rejected', - 'exec: fake-runner: not found', - 'fake-runner: No such file or directory', - 'fake-runner: Permission denied', + runnerFailureRules: [ + outerShellRule('fake-runner'), + { fatalSignatures: ['fake-runner: profile rejected'] }, ], }) expect(probeBwrap).not.toHaveBeenCalled() @@ -163,7 +174,10 @@ describe('the platform chains', () => { argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['read-only file system'], - runnerFailureSignatures: ['bwrap: '], + runnerFailureRules: [ + outerShellRule('bwrap'), + { fatalSignatures: ['bwrap: '] }, + ], }) expect(probeLandlock).not.toHaveBeenCalled() }) @@ -178,14 +192,21 @@ describe('the platform chains', () => { argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['permission denied'], - runnerFailureSignatures: ['landlock-run: '], + runnerFailureRules: [ + outerShellRule(launcher), + { + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }, + ], }) expect(probeLandlock).toHaveBeenCalledWith(launcher) }) it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => { // The safety property moves to execution time: an unusable sandbox-exec - // refuses to run the command, and the wrap's runnerFailureSignatures let + // refuses to run the command, and the wrap's runnerFailureRules let // the consumer classify that as a sandbox failure, not a task failure. const probeSeatbelt = vi.fn(() => true) const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt }) @@ -194,7 +215,10 @@ describe('the platform chains', () => { argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [ + outerShellRule('sandbox-exec'), + { fatalSignatures: ['sandbox-exec: '] }, + ], }) expect(probeSeatbelt).not.toHaveBeenCalled() }) @@ -360,7 +384,10 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [ + outerShellRule(exec), + { fatalSignatures: ['sandbox-exec: '] }, + ], }) }) diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 83b28c41b8..4fe0ad79e6 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -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/sandbox/sandbox/README.md -README.md: 99f0641560937f66df6db76ae55c90595329792f -README.zh.md: 5d7dfb303ea2405e725d162698c3ab3d39485636 +README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 +README.zh.md: 310b6bd5f526678e61d72846f154e7226495fca6 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 99f0641560..1b522b2c72 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. -The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. +The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus the selected backend's enforcement completeness, denial dialect (`denialSignatures`), and structured runner-failure evidence (`runnerFailureRules`); when no backend is usable it throws rather than passing the argv through unconfined. The [core type catalog](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) owns the exact classifier shape. Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy. @@ -39,4 +39,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions. - **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here. - **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output. +- **Runner diagnostics are in-band** — exit status plus stderr evidence cannot prove which process wrote a matching line, so a confined child that deliberately mimics its runner can cause an availability/diagnostic false attribution. This cannot bypass confinement; an out-of-band runner-status channel is deferred. - **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity. diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index 5d7dfb303e..310b6bd5f5 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -4,7 +4,7 @@ 抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包(package):只依赖 cordis(及 harness 错误基类),绝不依赖后端。 -用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件操作被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是统一的跨后端签名集合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。 +用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。 策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。 @@ -39,4 +39,5 @@ sandbox mode "" is requested but no sandbox backend is usable on this host - **文件操作是完整的策略词汇**:该 seam 不表达网络、进程、系统调用、设备或凭据限制。 - **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。 - **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。 +- **Runner 诊断使用带内通道**:退出状态与 stderr 证据无法证明匹配行由哪个进程写入,因此受限子进程若故意模仿 runner,就可能造成可用性或诊断误归因。这无法绕过约束;带外 runner 状态通道暂缓实现。 - **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 781227f411..aeca1ba8d6 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -62,6 +62,22 @@ export interface SandboxPolicy extends SandboxExecutionPolicy { mode: ConfinedSandboxMode } +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +export interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} + /** * A {@link SandboxProvider.confine} result: the argv to spawn in place of * the caller's own, plus the enforcement completeness the selected backend @@ -82,11 +98,12 @@ export interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b634898ea..a947b4b0cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3018,6 +3018,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3027,6 +3030,15 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3d1fcc8f9d..f8e56eab8d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -889,6 +889,11 @@ "symbol": "SandboxPolicyRequest", "source": "packages/sandbox/sandbox-policy/src/index.ts" }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "RunnerFailureRule", + "source": "packages/sandbox/sandbox/src/index.ts" + }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", From 019eb8b5522cfd99a366d82bc80dbf88b05741f5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 3 Aug 2026 18:07:18 +0800 Subject: [PATCH 046/108] test(acp): register Landlock snapshot fixture (round 3) --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index f1a6efef71..35aa5f5b12 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", From 5e05838365e1edd35b5cbc9d6fa5393ea7796fd3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 19:10:55 +0800 Subject: [PATCH 047/108] testing(web): re-record the markdown image golden for the model trigger The scenario arrived with master and recorded its trigger while the Host still synthesized an unlisted current model. This branch stopped doing that, so the trigger reads `Select model` until the user picks an advertised one. --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 58b72e0d65..ed8fee9178 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -24,8 +24,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current deepseek-v4-flash": - - text: deepseek-v4-flash +- button "Select model": + - text: Select model - img - button "Send message" [disabled] - text: 1 turns · 1 steps Input 0 tok · Output 0 tok From dc4d2dc86827f0202a53bb2df5aead3eb9c99659 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 20:26:39 +0800 Subject: [PATCH 048/108] docs(bash): document the bashEnv hard dependency and dedupe the demo manifest - tool-bash README (both languages) now shows the real four-service inject and attributes the session-persistence contributor to dsh-bash-env. - pwsh-local README names the also-exported ENV_OVERRIDES/ENCODING_PREAMBLE. - agent-spine-demo drops the duplicate bash-env devDependency key. --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/tool-bash/README.i18n.yaml | 4 ++-- packages/bash/tool-bash/README.md | 4 ++-- packages/bash/tool-bash/README.zh.md | 4 ++-- packages/examples/agent-spine-demo/package.json | 1 - 7 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index c097bc6e77..1b78ca75f9 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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: 76a30939d68ac866880f906b928b265628f4de1e -README.zh.md: af98af1b83baa72ad8345362b583b61fa634d29a +README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf +README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 76a30939d6..9deba9c1b6 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -6,7 +6,7 @@ Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam ove 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. -The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, and the pure `resolvePwshPath`/`candidatePwshPaths` helpers. +The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn. ## Config diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index af98af1b83..e45c820e1d 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -6,7 +6,7 @@ 命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。 -包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`,以及纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数。 +包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。 ## 配置 diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index fc7f0d443e..19370b6c64 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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: e3c8c445c8959b7f49705bb59af2bf61e0e716c8 -README.zh.md: fc5b3794187dcfd1f8b382bc805595c8ae887c55 +README.md: c168b3bfe49faec0be8bd7664411f538a8142edf +README.zh.md: 3b94f8bc2e4aca8ade15c4e2e1e35d1674c66fdb diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index e3c8c445c8..c168b3bfe4 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +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 package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. @@ -30,7 +30,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; `dsh-bash-env`'s session-persistence contributor owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. ```ts import type { Context } from 'cordis' diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index fc5b379418..3b94f8bc2e 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 -需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 @@ -30,7 +30,7 @@ 每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 +`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;`dsh-bash-env` 的会话持久化贡献方持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 ```ts import type { Context } from 'cordis' diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 10c6ce7e55..0d32a387ca 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -79,7 +79,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", - "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", From a228fe15a20a5558753f909ef57c6c01c8f67b82 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 20:31:43 +0800 Subject: [PATCH 049/108] docs(graph): regenerate the module graph for the merged tree The merge resolved the module-graph.md conflict by regenerating doc GRAPHS (gen-doc-graphs) while this file belongs to gen-module-graph; verify-module-graph is a static-lane gate outside doc-sync, so the staleness only surfaced on CI. --- docs/module-graph.md | 119 ++++++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 48 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index edb925960b..88de6c11ea 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,9 +38,12 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] + pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] + pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -504,6 +507,10 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -691,18 +698,11 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_paths - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_session_persistence - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval + pkg_bash_env --> pkg_bash + pkg_bash_env --> pkg_invariants + pkg_bash_env --> pkg_paths + pkg_bash_env --> pkg_session_persistence + pkg_bash_env --> pkg_tools pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -886,6 +886,25 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_bash_env + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_bash + pkg_tool_pwsh --> pkg_bash_env + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tasks + pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -984,27 +1003,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1040,6 +1038,39 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1060,17 +1091,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1159,6 +1179,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1199,7 +1220,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1230,6 +1251,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1242,14 +1265,14 @@ flowchart TD | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | From 90c9087a3ef6e61e7b90ff5758e92d3ffa4e5352 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 20:57:26 +0800 Subject: [PATCH 050/108] test(web): follow the structured abort message in the bash-abort-row scenario master's new disclosure test pins the literal the abort backport retired; the seeded cancel fixture now carries 'Error: tool call aborted', so the test literals and the ARIA golden follow (replay-verified on Linux). --- apps/web/tests/bash-abort-row.e2e.ts | 4 ++-- apps/web/tests/snapshots/bash-abort-row/ui.expected.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index 717cebdd36..3d48442747 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () const row = page.locator('[data-sample="bash"]').first() const call = row.locator('xpath=..') await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') @@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () await call.getByText('OUT', { exact: true }).waitFor() await call.getByText('Wait until cancellation', { exact: false }).waitFor() await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() - await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) // The borrowed fixture's UTC date is still the previous day in PDT; diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..c95c6b8de6 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -14,10 +14,10 @@ - img - img - text: Context injection -- 'button "Failed Bash Error: command aborted" [expanded]': +- 'button "Failed Bash Error: tool call aborted" [expanded]': - img - - text: "Failed Bash Error: command aborted" -- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" + - text: "Failed Bash Error: tool call aborted" +- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" - 'button "Failed Bash Error: tool call aborted before dispatch"': - img From 3d1166fcdd2a574d157deac2b7d7b03c35f1401e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 22:47:53 +0800 Subject: [PATCH 051/108] feat(bash): shell tools reject a mismatched executor dialect at load The seam gains ShellDialect ('bash' | 'powershell' - concrete shells, not families: zsh or fish would be their own values, never 'bash'); bash-local declares bash (bash-sandbox inherits), pwsh-local declares powershell, and both tools throw at load when the mounted executor speaks another dialect - previously tool-pwsh over bash-local handed PowerShell text to bash -c and the deployment error surfaced as ordinary nonzero exits. Pinned by mismatch tests on both tools; the parity note records the contract (both languages). Also from the review round: the tool-bash README's managed-environment section becomes a summary linking the owning dsh-bash-env contract (the duplicated prose carried a stale owner in its example import), the pwshOnly JSDoc drops the stale 'on PATH' phrasing, and the task-tools contract comment in the two pwsh compositions is indented into its block. --- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 ++-- .../2026-08-02-pwsh-tool-bash-parity.md | 1 + .../2026-08-02-pwsh-tool-bash-parity.zh.md | 1 + docs/cordis-catalog/services.md | 2 +- .../acp-agent/tests/pwsh.cordis.snapshot.yml | 3 ++- examples/acp-agent/tests/pwsh.cordis.yml | 3 ++- packages/bash/bash-local/src/index.ts | 2 ++ packages/bash/bash/README.i18n.yaml | 4 ++-- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/bash/src/index.ts | 16 +++++++++++++ packages/bash/bash/tests/service.spec.ts | 2 ++ packages/bash/pwsh-local/src/index.ts | 2 ++ packages/bash/tool-bash/README.i18n.yaml | 4 ++-- packages/bash/tool-bash/README.md | 23 ++----------------- packages/bash/tool-bash/README.zh.md | 23 ++----------------- packages/bash/tool-bash/src/index.ts | 5 ++++ packages/bash/tool-bash/tests/tools.spec.ts | 21 ++++++++++++++++- packages/bash/tool-pwsh/README.i18n.yaml | 4 ++-- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 5 ++++ packages/bash/tool-pwsh/tests/tools.spec.ts | 17 +++++++++++++- .../tmux-context/tests/tmux-context.spec.ts | 2 ++ packages/support/acp-snapshot/src/suite.ts | 2 +- t1120.json | 1 + zh-tail.txt | 3 +++ 27 files changed, 98 insertions(+), 60 deletions(-) create mode 100644 t1120.json create mode 100644 zh-tail.txt diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 6cbc24d8aa..d5b4798e33 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 417c6d6bc91eb3afaa38976e0013e4fbe72854ca -2026-08-02-pwsh-tool-bash-parity.zh.md: 926433526b3820f7b4770acb3ee172448970b601 +2026-08-02-pwsh-tool-bash-parity.md: 286acbce3289331a107924120da91464b2303bab +2026-08-02-pwsh-tool-bash-parity.zh.md: 4de34c1416821436c156b1b19b7ae775779e8915 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 417c6d6bc9..286acbce32 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -29,6 +29,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- The seam now carries a `dialect` (`ShellDialect`: `bash` | `powershell` — concrete shells, not families; a POSIX-ish sibling would be its own value), and both shell tools reject a mismatched executor at load, so pairing tool-pwsh with bash-local (or the reverse) fails loud instead of surfacing commands handed to the wrong parser as ordinary nonzero exits. - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. - The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. - The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 926433526b..4de34c1416 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -29,6 +29,7 @@ Status: implemented - bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- seam 现在携带 `dialect`(`ShellDialect`:`bash` | `powershell`——具体 shell 而非家族;类 POSIX 的同胞将是自己的值),两个 shell 工具在加载时拒绝不匹配的执行器:把 tool-pwsh 与 bash-local 误配(或反之)会响亮失败,而不是让交错 parser 的命令表现为普通非零退出。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 - pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 - 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb6993962c..111741a1a7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -293,7 +293,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml index 91fdeabf53..9daab43aff 100644 --- a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -27,7 +27,8 @@ workspaceContext: false skills: enabled: false -# task_output/task_kill stay mounted so background pwsh runs are readable and killable. + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index cb8305c7d9..7021ae2116 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -26,7 +26,8 @@ workspaceContext: false skills: enabled: false -# task_output/task_kill stay mounted so background pwsh runs are readable and killable. + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..7bc57fad0b 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -80,6 +80,8 @@ function assertPositiveFinite(name: string, value: number): void { export class LocalBashExecutor extends BashExecutor { static inject = ['subprocess'] + readonly dialect = 'bash' as const + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index 4a2c37ad91..de2e7510e0 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -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: d7bf746969f52000fe298b65b995b7c631d8001c -README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c +README.md: b4dbfb6cd9af92d65e30be8d0237b17611b6cbe8 +README.zh.md: 8cf73948970bf0ba465d04b5bedc7e4146fdd217 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index d7bf746969..b4dbfb6cd9 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -2,7 +2,7 @@ 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 **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. Every implementation declares its `dialect` (`ShellDialect`: the concrete shell that parses the command string, `bash` or `powershell`), and the model-facing shell tools reject a mismatched executor at load. 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: diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index a7c0cac0bc..8cf7394897 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 +**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。每个实现声明自己的 `dialect`(`ShellDialect`:解析命令字符串的具体 shell,`bash` 或 `powershell`),模型侧 shell 工具在加载时拒绝不匹配的执行器。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 4f8ae112a9..756f866e73 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -29,6 +29,14 @@ declare module 'cordis' { } } +/** + * The shell language a command string is written in. Values name concrete + * shells, not families — the executor hands the string verbatim to that + * shell's parser (`bash -c`, `pwsh -Command`), so a POSIX-ish sibling such + * as zsh or fish would be its own dialect, never `bash`. + */ +export type ShellDialect = 'bash' | 'powershell' + /** * Abstract bash execution service. Subclass, implement the abstract methods, * and load the subclass as a plugin — it registers as `ctx.bash` (one @@ -53,6 +61,14 @@ export abstract class BashExecutor extends Service { super(ctx, 'bash') } + /** + * The shell dialect this executor's `run`/`start` parse commands with. + * Model-facing shell tools reject a mismatched executor at load + * (misconfiguration fails loud): a PowerShell command handed to `bash -c` + * would otherwise surface as an ordinary nonzero exit. + */ + abstract readonly dialect: ShellDialect + /** * The sandbox mode this executor applies by default, or `undefined` when it * does not sandbox commands. diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index cacfe85eca..33eb9a61b0 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -10,6 +10,8 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR * owes the abstract class. */ class StubExecutor extends BashExecutor { + readonly dialect = 'bash' as const + resolve(request: BashExecRequest): BashExecSpec { return { command: request.command, diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 316d2c8651..8fc7f43054 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -104,6 +104,8 @@ function assertPositiveFinite(name: string, value: number): void { export class PwshLocalExecutor extends BashExecutor { static inject = ['subprocess'] + readonly dialect = 'powershell' as const + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 19370b6c64..6eaff6daa5 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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: c168b3bfe49faec0be8bd7664411f538a8142edf -README.zh.md: 3b94f8bc2e4aca8ade15c4e2e1e35d1674c66fdb +README.md: be89acf06dbc6b4c420dd7fd34eb6ccb842c1fbd +README.zh.md: 7886addd4c941d81a90546518a5967f64edcdf61 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index c168b3bfe4..be89acf06d 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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']`). +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']`), and rejects an executor whose `dialect` is not `bash` at load — a bash command handed to another shell's parser would surface as an ordinary nonzero exit. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. @@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. - -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; `dsh-bash-env`'s session-persistence contributor owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. +Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) 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. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 3b94f8bc2e..7886addd4c 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `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']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`),并在加载时拒绝 `dialect` 不为 `bash` 的执行器——bash 命令被交给其他 shell 解析只会表现为普通的非零退出。 包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 @@ -28,26 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 - -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;`dsh-bash-env` 的会话持久化贡献方持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `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`。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 91a88c9cca..41a2491375 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -188,6 +188,11 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { + // Model commands are written in bash; a mismatched executor would hand + // them to another shell's parser and surface as ordinary nonzero exits. + if (ctx.bash.dialect !== 'bash') { + throw new Error(`tool-bash: the mounted executor speaks '${ctx.bash.dialect}', not bash — mount a bash executor (e.g. dsh-bash-local) or the matching shell tool`) + } const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4f913d78ee..9cb2330928 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -101,6 +101,8 @@ async function callUntilText( } class RecordingSandboxExecutor extends BashExecutor { + readonly dialect = 'bash' as const + readonly modes: Array = [] override get sandboxMode() { @@ -154,6 +156,8 @@ class RecordingSandboxExecutor extends BashExecutor { /** Test executor that records whether the background start boundary was crossed. */ class CountingStartExecutor extends BashExecutor { + readonly dialect: ShellDialect = 'bash' + starts = 0 resolve(request: BashExecRequest): BashExecSpec { @@ -361,6 +365,19 @@ describe('bash tool', () => { expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable') }) + it('rejects an executor speaking another shell dialect at load', async () => { + class PowershellDialectExecutor extends CountingStartExecutor { + override readonly dialect: ShellDialect = 'powershell' + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PowershellDialectExecutor) + await expect(ctx.plugin(ToolBash)).rejects.toThrow("the mounted executor speaks 'powershell', not bash") + }) + it('registers the bash schema with run_in_background exposed by default', async () => { const ctx = await setup() const schemas = ctx.tools.schemas() @@ -1067,6 +1084,8 @@ describe('the model-facing bash tool builds its request from named args only (no * hands back an already-settled fake handle so the task registration completes. */ class RecordingBashExecutor extends BashExecutor { + readonly dialect = 'bash' as const + readonly requests: BashExecRequest[] = [] resolve(request: BashExecRequest): BashExecSpec { this.requests.push(request) diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 030d24c7c2..642f2c2089 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -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: dfe26a63684d61dcdd6f969c2c2261dac79325c7 -README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 +README.md: 7bc1c0998a67ee772ec54eb46bebed52e5164588 +README.zh.md: 4f74b7cb4b737fd4c4f788586568676423f59cd4 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index dfe26a6368..7bc1c0998a 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -4,7 +4,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). -Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`), and rejects an executor whose `dialect` is not `powershell` at load — a PowerShell command handed to `bash -c` would surface as an ordinary nonzero exit. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 2344f8477e..4f74b7cb4b 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -4,7 +4,7 @@ 注册在 `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)。 -需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`),并在加载时拒绝 `dialect` 不为 `powershell` 的执行器——PowerShell 命令被交给 `bash -c` 只会表现为普通的非零退出。 包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9423fe36e6..969a9a777f 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -138,6 +138,11 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-end */ export function apply(ctx: Context, config: Config = {}): void { + // Model commands are written in PowerShell; a mismatched executor would + // hand them to bash and surface as ordinary nonzero exits. + if (ctx.bash.dialect !== 'powershell') { + throw new Error(`tool-pwsh: the mounted executor speaks '${ctx.bash.dialect}', not powershell — mount dsh-pwsh-local or the matching shell tool`) + } const backgroundEnabled = config.enableRunInBackground ?? true ctx.systemPrompt.section({ diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 218099326f..620be2f354 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -23,7 +23,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' 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' @@ -38,6 +38,8 @@ const testToolSignal = new AbortController().signal * handle. */ class FakeBash extends BashExecutor { + readonly dialect: ShellDialect = 'powershell' + requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 @@ -199,6 +201,19 @@ async function callUntilText( } describe('registration', () => { + it('rejects an executor speaking another shell dialect at load', async () => { + class BashDialectExecutor extends FakeBash { + override readonly dialect = 'bash' as const + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(BashDialectExecutor) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow("the mounted executor speaks 'bash', not powershell") + }) + it('registers the pwsh tool with its prompt section and schema', async () => { const { ctx } = await setup() const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 1d94399184..54954f8e4d 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -48,6 +48,8 @@ function runResult(stdout: string, overrides: Partial = {}): Bash /** A scriptable fake `ctx.bash` recording the command it was asked to run. */ class FakeBash extends BashExecutor { + readonly dialect = 'bash' as const + commands: string[] = [] result: BashRunResult = runResult(`${tmuxLine()}\n`) runError?: Error diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index f9e97dd45b..db59c9265b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -162,7 +162,7 @@ export interface Scenario { */ posixOnly?: boolean /** - * Whether the scenario boots a composition that needs a real `pwsh` on PATH + * Whether the scenario boots a composition that needs a usable `pwsh` * (the pwsh-tool-turn scenario). The run test is skipped when the suite's * {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded * on every platform. diff --git a/t1120.json b/t1120.json new file mode 100644 index 0000000000..3e064c460c --- /dev/null +++ b/t1120.json @@ -0,0 +1 @@ +[{"comments":{"nodes":[{"body":"🟡 **warning**: Duplicated contract with a stale example import: the §Managed shell environment section carries the full bashEnv registry contract (both prose paragraphs, the contributor code example, and the overlay paragraph) near-verbatim duplicated in this PR's new owning home, packages/bash/bash-env/README.md. The tool-bash copy's example still reads `import type {} from '@deepseek-ai/dsh-tool-bash'` to get `ctx.bashEnv` typed — but this PR moved the `declare module 'cordis'` merge to dsh-bash-env (tool-bash only type-imports it, so the example compiles transitively but names the wrong owner). tool-pwsh's README shows the intended post-extraction shape: a one-paragraph summary linking to ../bash-env/. Trim tool-bash's section to the bash-specific facts plus the link, keep the contract and example in bash-env only (one home per fact; symmetry between the two shell tools), update both language sides and re-record the pairing. Non-blocking, but worth fixing before merge since the stale example is a direct consequence of this PR's extraction.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8o","isResolved":false,"line":30,"path":"packages/bash/tool-bash/README.md"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Nit: `Scenario.pwshOnly`'s JSDoc says the composition \"needs a real `pwsh` on PATH\", but the probe is caller-owned and the shipped caller (examples/acp-agent/tests/acp.snapshot.ts:441) deliberately follows resolvePwshPath() — Program Files installs are found even when bare `pwsh` is not on PATH. This is the same stale \"on PATH\" phrasing already corrected in the executor.spec.ts and integration.spec.ts headers this round. One-word fix: \"a usable `pwsh`\".\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8w","isResolved":false,"line":164,"path":"packages/support/acp-snapshot/src/suite.ts"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Optional nit: the `# task_output/task_kill stay mounted so background pwsh runs are readable and killable.` contract comment sits at column 0 mid-way through the acp-agent config map (between `skills` and `goals` keys), in both pwsh.cordis.yml and pwsh.cordis.snapshot.yml. Valid YAML and a genuinely useful pin (the mounting decision is the bundle's toolTasks default, invisible in this file), but indent it into the block or move it above the composition's plugin list where the decision reads naturally.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H82","isResolved":false,"line":30,"path":"examples/acp-agent/tests/pwsh.cordis.yml"},{"comments":{"nodes":[{"body":"🟡 **warning**: 拒绝 shell 工具与执行器方言不匹配: 当组合误将 `tool-pwsh` 与 `dsh-bash-local` 搭配时,当前注入会正常完成,PowerShell 命令却被交给 `bash -c`,通常只返回普通的非零退出标记;反向将 `tool-bash` 搭配 `pwsh-local` 也一样。新增第二种方言后,`ctx.bash` 需要暴露可校验的方言能力,并让两个工具在加载时拒绝不匹配的执行器,否则明显的部署错误会静默表现为命令失败。\n\n\u003csub\u003e🤖 v6\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H88","isResolved":false,"line":141,"path":"packages/bash/tool-pwsh/src/index.ts"}] diff --git a/zh-tail.txt b/zh-tail.txt new file mode 100644 index 0000000000..3429afb031 --- /dev/null +++ b/zh-tail.txt @@ -0,0 +1,3 @@ +overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 + +结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 From 9ae358e0876fbd6864266ff30a0e3082a884a3a3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 22:48:49 +0800 Subject: [PATCH 052/108] chore: drop a stray scratch file --- zh-tail.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 zh-tail.txt diff --git a/zh-tail.txt b/zh-tail.txt deleted file mode 100644 index 3429afb031..0000000000 --- a/zh-tail.txt +++ /dev/null @@ -1,3 +0,0 @@ -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 - -结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 From b43358e215719e158b811c19f1ae493038188af3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 22:50:25 +0800 Subject: [PATCH 053/108] chore: drop another stray scratch file --- t1120.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 t1120.json diff --git a/t1120.json b/t1120.json deleted file mode 100644 index 3e064c460c..0000000000 --- a/t1120.json +++ /dev/null @@ -1 +0,0 @@ -[{"comments":{"nodes":[{"body":"🟡 **warning**: Duplicated contract with a stale example import: the §Managed shell environment section carries the full bashEnv registry contract (both prose paragraphs, the contributor code example, and the overlay paragraph) near-verbatim duplicated in this PR's new owning home, packages/bash/bash-env/README.md. The tool-bash copy's example still reads `import type {} from '@deepseek-ai/dsh-tool-bash'` to get `ctx.bashEnv` typed — but this PR moved the `declare module 'cordis'` merge to dsh-bash-env (tool-bash only type-imports it, so the example compiles transitively but names the wrong owner). tool-pwsh's README shows the intended post-extraction shape: a one-paragraph summary linking to ../bash-env/. Trim tool-bash's section to the bash-specific facts plus the link, keep the contract and example in bash-env only (one home per fact; symmetry between the two shell tools), update both language sides and re-record the pairing. Non-blocking, but worth fixing before merge since the stale example is a direct consequence of this PR's extraction.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8o","isResolved":false,"line":30,"path":"packages/bash/tool-bash/README.md"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Nit: `Scenario.pwshOnly`'s JSDoc says the composition \"needs a real `pwsh` on PATH\", but the probe is caller-owned and the shipped caller (examples/acp-agent/tests/acp.snapshot.ts:441) deliberately follows resolvePwshPath() — Program Files installs are found even when bare `pwsh` is not on PATH. This is the same stale \"on PATH\" phrasing already corrected in the executor.spec.ts and integration.spec.ts headers this round. One-word fix: \"a usable `pwsh`\".\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8w","isResolved":false,"line":164,"path":"packages/support/acp-snapshot/src/suite.ts"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Optional nit: the `# task_output/task_kill stay mounted so background pwsh runs are readable and killable.` contract comment sits at column 0 mid-way through the acp-agent config map (between `skills` and `goals` keys), in both pwsh.cordis.yml and pwsh.cordis.snapshot.yml. Valid YAML and a genuinely useful pin (the mounting decision is the bundle's toolTasks default, invisible in this file), but indent it into the block or move it above the composition's plugin list where the decision reads naturally.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H82","isResolved":false,"line":30,"path":"examples/acp-agent/tests/pwsh.cordis.yml"},{"comments":{"nodes":[{"body":"🟡 **warning**: 拒绝 shell 工具与执行器方言不匹配: 当组合误将 `tool-pwsh` 与 `dsh-bash-local` 搭配时,当前注入会正常完成,PowerShell 命令却被交给 `bash -c`,通常只返回普通的非零退出标记;反向将 `tool-bash` 搭配 `pwsh-local` 也一样。新增第二种方言后,`ctx.bash` 需要暴露可校验的方言能力,并让两个工具在加载时拒绝不匹配的执行器,否则明显的部署错误会静默表现为命令失败。\n\n\u003csub\u003e🤖 v6\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H88","isResolved":false,"line":141,"path":"packages/bash/tool-pwsh/src/index.ts"}] From f14f826648bbb7280bf06c8abd4bf5d95e7c0483 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:16:53 +0800 Subject: [PATCH 054/108] revert(bash): drop the executor dialect guard The ShellDialect marker on BashExecutor and the load-time rejection in tool-bash/tool-pwsh force every test and example fake executor to declare a dialect for a guard with no in-repo or plausible deployment to catch (shipped compositions always pair the tool with its matching executor), and they break the example composition suites that stub ctx.bash without a dialect (agent-spine-demo/cli-demo/acp-demo lost the bash tool). Keep the non-dialect doc fixes that landed with the attempt: the tool-bash README bashEnv contract trim with the stale example import removed, the acp-snapshot suite.ts 'usable pwsh' JSDoc, and the pwsh.cordis.yml comment indent. The parity note records the attempt and revert under Alternatives. --- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 ++-- .../2026-08-02-pwsh-tool-bash-parity.md | 3 ++- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 3 ++- docs/cordis-catalog/services.md | 2 +- packages/bash/bash-local/src/index.ts | 2 -- packages/bash/bash/README.i18n.yaml | 4 ++-- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/bash/src/index.ts | 16 -------------- packages/bash/bash/tests/service.spec.ts | 2 -- packages/bash/pwsh-local/src/index.ts | 2 -- packages/bash/tool-bash/README.i18n.yaml | 4 ++-- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/README.zh.md | 2 +- packages/bash/tool-bash/src/index.ts | 5 ----- packages/bash/tool-bash/tests/tools.spec.ts | 21 +------------------ packages/bash/tool-pwsh/README.i18n.yaml | 4 ++-- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 5 ----- packages/bash/tool-pwsh/tests/tools.spec.ts | 17 +-------------- .../tmux-context/tests/tmux-context.spec.ts | 2 -- 22 files changed, 21 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index d5b4798e33..e17e4685e3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 286acbce3289331a107924120da91464b2303bab -2026-08-02-pwsh-tool-bash-parity.zh.md: 4de34c1416821436c156b1b19b7ae775779e8915 +2026-08-02-pwsh-tool-bash-parity.md: bf40c440b9f7f330412d8949f59c54d541152d45 +2026-08-02-pwsh-tool-bash-parity.zh.md: bc67dac29900eacf9e615e76fe947e3e642a3979 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 286acbce32..bf40c440b9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -22,6 +22,8 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi **Keep the minimal profile and fix only the claims.** Rejected: the review's core finding was that text contracts copied from bash drift without the corresponding implementation; a minimal tool plus accurate claims still leaves pwsh calls without background execution, without contributor parity, and with a divergent marker story that must be re-justified forever. +**Reject a mismatched executor dialect at load.** Attempted and reverted before merge: a `ShellDialect` marker (`bash` | `powershell`) on `BashExecutor`, with both shell tools throwing when the mounted executor speaks another shell. It forced every executor implementation — including each test and example fake — to declare a dialect, adding noise to every shell-tool test for a guard with no in-repo or plausible deployment to catch (shipped compositions always pair tool-pwsh with `dsh-pwsh-local` and tool-bash with `dsh-bash-local`). The pairing contract stays documented in each tool's README instead. + **Extract a fully shared tool implementation base (abstract shell dialect, two thin leaves).** Considered and deferred: the bash-env extraction and the structural mirror (`render.ts`/`background.ts` twins) are the foundation it would rest on; a full base waits until a third dialect or the persistent-PTY twin makes the abstraction's shape observable. ## Consequences @@ -29,7 +31,6 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). -- The seam now carries a `dialect` (`ShellDialect`: `bash` | `powershell` — concrete shells, not families; a POSIX-ish sibling would be its own value), and both shell tools reject a mismatched executor at load, so pairing tool-pwsh with bash-local (or the reverse) fails loud instead of surfacing commands handed to the wrong parser as ordinary nonzero exits. - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. - The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. - The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 4de34c1416..bc67dac299 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -22,6 +22,8 @@ Status: implemented **保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。 +**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对契约改由各工具 README 记录。 + **提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。 ## 后果 @@ -29,7 +31,6 @@ Status: implemented - bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 -- seam 现在携带 `dialect`(`ShellDialect`:`bash` | `powershell`——具体 shell 而非家族;类 POSIX 的同胞将是自己的值),两个 shell 工具在加载时拒绝不匹配的执行器:把 tool-pwsh 与 bash-local 误配(或反之)会响亮失败,而不是让交错 parser 的命令表现为普通非零退出。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 - pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 - 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 111741a1a7..eb6993962c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -293,7 +293,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 7bc57fad0b..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -80,8 +80,6 @@ function assertPositiveFinite(name: string, value: number): void { export class LocalBashExecutor extends BashExecutor { static inject = ['subprocess'] - readonly dialect = 'bash' as const - static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index de2e7510e0..4a2c37ad91 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -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: b4dbfb6cd9af92d65e30be8d0237b17611b6cbe8 -README.zh.md: 8cf73948970bf0ba465d04b5bedc7e4146fdd217 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4dbfb6cd9..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -2,7 +2,7 @@ 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. Every implementation declares its `dialect` (`ShellDialect`: the concrete shell that parses the command string, `bash` or `powershell`), and the model-facing shell tools reject a mismatched executor at load. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime. +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. This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently: diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 8cf7394897..a7c0cac0bc 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。每个实现声明自己的 `dialect`(`ShellDialect`:解析命令字符串的具体 shell,`bash` 或 `powershell`),模型侧 shell 工具在加载时拒绝不匹配的执行器。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 +**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 756f866e73..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -29,14 +29,6 @@ declare module 'cordis' { } } -/** - * The shell language a command string is written in. Values name concrete - * shells, not families — the executor hands the string verbatim to that - * shell's parser (`bash -c`, `pwsh -Command`), so a POSIX-ish sibling such - * as zsh or fish would be its own dialect, never `bash`. - */ -export type ShellDialect = 'bash' | 'powershell' - /** * Abstract bash execution service. Subclass, implement the abstract methods, * and load the subclass as a plugin — it registers as `ctx.bash` (one @@ -61,14 +53,6 @@ export abstract class BashExecutor extends Service { super(ctx, 'bash') } - /** - * The shell dialect this executor's `run`/`start` parse commands with. - * Model-facing shell tools reject a mismatched executor at load - * (misconfiguration fails loud): a PowerShell command handed to `bash -c` - * would otherwise surface as an ordinary nonzero exit. - */ - abstract readonly dialect: ShellDialect - /** * The sandbox mode this executor applies by default, or `undefined` when it * does not sandbox commands. diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 33eb9a61b0..cacfe85eca 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -10,8 +10,6 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR * owes the abstract class. */ class StubExecutor extends BashExecutor { - readonly dialect = 'bash' as const - resolve(request: BashExecRequest): BashExecSpec { return { command: request.command, diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 8fc7f43054..316d2c8651 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -104,8 +104,6 @@ function assertPositiveFinite(name: string, value: number): void { export class PwshLocalExecutor extends BashExecutor { static inject = ['subprocess'] - readonly dialect = 'powershell' as const - static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 6eaff6daa5..c22dd9c62c 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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: be89acf06dbc6b4c420dd7fd34eb6ccb842c1fbd -README.zh.md: 7886addd4c941d81a90546518a5967f64edcdf61 +README.md: f21b6b4344fcfb94d88f1f7e1c4444ee89e0ead8 +README.zh.md: c9dd3f2250631f3300edaf2c246e6925f3e12005 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index be89acf06d..f21b6b4344 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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']`), and rejects an executor whose `dialect` is not `bash` at load — a bash command handed to another shell's parser would surface as an ordinary nonzero exit. +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 package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 7886addd4c..c9dd3f2250 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `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']`),并在加载时拒绝 `dialect` 不为 `bash` 的执行器——bash 命令被交给其他 shell 解析只会表现为普通的非零退出。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 41a2491375..91a88c9cca 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -188,11 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { - // Model commands are written in bash; a mismatched executor would hand - // them to another shell's parser and surface as ordinary nonzero exits. - if (ctx.bash.dialect !== 'bash') { - throw new Error(`tool-bash: the mounted executor speaks '${ctx.bash.dialect}', not bash — mount a bash executor (e.g. dsh-bash-local) or the matching shell tool`) - } const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9cb2330928..4f913d78ee 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -101,8 +101,6 @@ async function callUntilText( } class RecordingSandboxExecutor extends BashExecutor { - readonly dialect = 'bash' as const - readonly modes: Array = [] override get sandboxMode() { @@ -156,8 +154,6 @@ class RecordingSandboxExecutor extends BashExecutor { /** Test executor that records whether the background start boundary was crossed. */ class CountingStartExecutor extends BashExecutor { - readonly dialect: ShellDialect = 'bash' - starts = 0 resolve(request: BashExecRequest): BashExecSpec { @@ -365,19 +361,6 @@ describe('bash tool', () => { expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable') }) - it('rejects an executor speaking another shell dialect at load', async () => { - class PowershellDialectExecutor extends CountingStartExecutor { - override readonly dialect: ShellDialect = 'powershell' - } - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(PowershellDialectExecutor) - await expect(ctx.plugin(ToolBash)).rejects.toThrow("the mounted executor speaks 'powershell', not bash") - }) - it('registers the bash schema with run_in_background exposed by default', async () => { const ctx = await setup() const schemas = ctx.tools.schemas() @@ -1084,8 +1067,6 @@ describe('the model-facing bash tool builds its request from named args only (no * hands back an already-settled fake handle so the task registration completes. */ class RecordingBashExecutor extends BashExecutor { - readonly dialect = 'bash' as const - readonly requests: BashExecRequest[] = [] resolve(request: BashExecRequest): BashExecSpec { this.requests.push(request) diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 642f2c2089..030d24c7c2 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -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: 7bc1c0998a67ee772ec54eb46bebed52e5164588 -README.zh.md: 4f74b7cb4b737fd4c4f788586568676423f59cd4 +README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 +README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 7bc1c0998a..dfe26a6368 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -4,7 +4,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). -Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`), and rejects an executor whose `dialect` is not `powershell` at load — a PowerShell command handed to `bash -c` would surface as an ordinary nonzero exit. +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 4f74b7cb4b..2344f8477e 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -4,7 +4,7 @@ 注册在 `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)。 -需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`),并在加载时拒绝 `dialect` 不为 `powershell` 的执行器——PowerShell 命令被交给 `bash -c` 只会表现为普通的非零退出。 +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 969a9a777f..9423fe36e6 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -138,11 +138,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-end */ export function apply(ctx: Context, config: Config = {}): void { - // Model commands are written in PowerShell; a mismatched executor would - // hand them to bash and surface as ordinary nonzero exits. - if (ctx.bash.dialect !== 'powershell') { - throw new Error(`tool-pwsh: the mounted executor speaks '${ctx.bash.dialect}', not powershell — mount dsh-pwsh-local or the matching shell tool`) - } const backgroundEnabled = config.enableRunInBackground ?? true ctx.systemPrompt.section({ diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 620be2f354..218099326f 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -23,7 +23,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' 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' @@ -38,8 +38,6 @@ const testToolSignal = new AbortController().signal * handle. */ class FakeBash extends BashExecutor { - readonly dialect: ShellDialect = 'powershell' - requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 @@ -201,19 +199,6 @@ async function callUntilText( } describe('registration', () => { - it('rejects an executor speaking another shell dialect at load', async () => { - class BashDialectExecutor extends FakeBash { - override readonly dialect = 'bash' as const - } - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(BashDialectExecutor) - await expect(ctx.plugin(ToolPwsh)).rejects.toThrow("the mounted executor speaks 'bash', not powershell") - }) - it('registers the pwsh tool with its prompt section and schema', async () => { const { ctx } = await setup() const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 54954f8e4d..1d94399184 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -48,8 +48,6 @@ function runResult(stdout: string, overrides: Partial = {}): Bash /** A scriptable fake `ctx.bash` recording the command it was asked to run. */ class FakeBash extends BashExecutor { - readonly dialect = 'bash' as const - commands: string[] = [] result: BashRunResult = runResult(`${tmuxLine()}\n`) runError?: Error From d11b8286830371800d60944cae932f5dfaf30c8d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:16:59 +0800 Subject: [PATCH 055/108] docs(acp-snapshot): document Scenario.pwshOnly and the hasPwsh probe The host-variance paragraph documented posixOnly and pinsNativeWindowsStdout but not the pwshOnly flag and SnapshotSuiteOptions.hasPwsh probe added with the pwsh-tool-turn scenario; add the parallel sentence on both language sides and re-record the pairing. --- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 376cea9dcd..4fa3b34202 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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/support/acp-snapshot/README.md -README.md: e7988733827ef1d4de67d6d49764a4836e33d17c -README.zh.md: e2466feb5e2025cb99f252b4206bfacb711bccda +README.md: 015eda6b25202f219a49f10e286b6c22061171bf +README.zh.md: 2581dd4b8e270adaebea46144045180df4cc0582 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index e798873382..015eda6b25 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the suite's `hasPwsh` probe follows the executor's own resolution (so Program Files installs count), and the run test is skipped when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index e2466feb5e..2581dd4b8e 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;套件的 `hasPwsh` 探测遵循执行器自身的解析(因此 Program Files 安装也计入),在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 From b50120e090802dc69942e528aeac31d4c403daee Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:38:41 +0800 Subject: [PATCH 056/108] docs: attribute the hasPwsh probe to the caller and name the bash dialect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-four nits: the acp-snapshot README said 'the suite's hasPwsh probe follows the executor's own resolution' — hasPwsh is caller-supplied, and the resolvePwshPath-following behavior belongs to the shipped acp-agent caller; reword to 'the caller-supplied hasPwsh probe (the shipped acp-agent suite follows the executor's own resolution...)' on both language sides. And the tool-bash README loses its only explicit pairing sentence with the dialect guard reverted, so state the contract plainly: 'The tool contract is bash-dialect — mount a bash-parsing executor' (both languages, pairing re-recorded). --- packages/bash/tool-bash/README.i18n.yaml | 4 ++-- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/README.zh.md | 2 +- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index c22dd9c62c..fb33cffefb 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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: f21b6b4344fcfb94d88f1f7e1c4444ee89e0ead8 -README.zh.md: c9dd3f2250631f3300edaf2c246e6925f3e12005 +README.md: 35a5647365dab6daa903c14cba8b83702b50305d +README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index f21b6b4344..35a5647365 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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']`). +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. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index c9dd3f2250..eb7901f344 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `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']`)。 +需要加载执行器实现(例如 `@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`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 4fa3b34202..362de9fbd1 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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/support/acp-snapshot/README.md -README.md: 015eda6b25202f219a49f10e286b6c22061171bf -README.zh.md: 2581dd4b8e270adaebea46144045180df4cc0582 +README.md: c8fe6907848a7b661c4bfb60c871f077753fda8b +README.zh.md: 0aa28f4b2c3df1a30e5fd33a80401712f81d6614 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 015eda6b25..c8fe690784 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the suite's `hasPwsh` probe follows the executor's own resolution (so Program Files installs count), and the run test is skipped when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 2581dd4b8e..0aa28f4b2c 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;套件的 `hasPwsh` 探测遵循执行器自身的解析(因此 Program Files 安装也计入),在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 From 2eedb54849c438c4941a3b89692c42b65eac1288 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 09:37:50 +0800 Subject: [PATCH 057/108] fix(examples): declare sandbox fixture dependency --- examples/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/examples/package.json b/examples/package.json index b1a32fc381..99d57bf4a6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-repository-plugin": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f109f8c62..26184115cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -677,6 +677,9 @@ importers: '@deepseek-ai/dsh-repository-plugin': specifier: workspace:* version: link:../packages/cordis/repository-plugin + '@deepseek-ai/dsh-sandbox': + specifier: workspace:* + version: link:../packages/sandbox/sandbox '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../packages/sandbox/sandbox-local From e36d040d0abf1659a2017c3a86db76aef939077d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 12:04:48 +0800 Subject: [PATCH 058/108] fix(sandbox): spawn confined argv directly (round 1) --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 22 ++-- .../feature/2026-07-06-sandbox.zh.md | 22 ++-- docs/config-catalog.md | 16 +-- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 4 +- docs/core-data-structures/sandbox.zh.md | 4 +- ...ice-misclassified-child-failures.i18n.yaml | 4 +- ...ial-notice-misclassified-child-failures.md | 6 +- ...-notice-misclassified-child-failures.zh.md | 6 +- .../fixtures/partial-landlock-sandbox.ts | 15 ++- examples/package.json | 3 +- native/landlock-run/README.i18n.yaml | 4 +- native/landlock-run/README.md | 2 +- native/landlock-run/README.zh.md | 2 +- native/landlock-run/docs/cli-contract.md | 10 +- .../landlock-run/packages/entry/src/index.ts | 13 +- native/landlock-run/test/entry.test.js | 4 + native/landlock-run/test/launcher.test.js | 10 ++ packages/bash/bash-local/src/index.ts | 56 +++++++-- packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 8 +- packages/bash/bash-sandbox/README.zh.md | 8 +- packages/bash/bash-sandbox/src/helpers.ts | 11 +- packages/bash/bash-sandbox/src/index.ts | 51 ++++---- .../tests/partial-landlock.spec.ts | 118 ++++++++++++------ .../bash/bash-sandbox/tests/sandbox.spec.ts | 89 +++++++++---- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 28 ++++- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 4 +- packages/sandbox/sandbox-local/README.zh.md | 4 +- packages/sandbox/sandbox-local/src/index.ts | 59 +++------ .../sandbox/sandbox-local/tests/local.spec.ts | 72 ++++------- pnpm-lock.yaml | 3 + 34 files changed, 392 insertions(+), 282 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index e1c1e00880..a30b48d5ae 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 85c7f64d379e4fec18511b3b8566e84edb34c1a2 -2026-07-06-sandbox.zh.md: 0278b6302d2647c49980b10646e3b9158356b3ec +2026-07-06-sandbox.md: c3f78c4bd05063c7c34621e6a3ec007d394b6d4c +2026-07-06-sandbox.zh.md: 984a8d71854344d41280e956b3c985659a9b066c diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 85c7f64d37..c3f78c4bd0 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -36,9 +36,9 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs ``` -The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before, spawned through the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. +The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. -Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. +Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner cannot spawn, the consumer reports the same infrastructure error from the spawn channel before any command starts. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The owner-derived runtime context states the current file policy without replacing those enforcement boundaries. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly. @@ -60,17 +60,17 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Outer-shell failures are separate rules scoped to the resolved/configured argv0 and shell exit 126/127. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config and maps those entries into an internal fatal rule, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so missing or unexecutable runners fail through the spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured Bash script necessarily owns its interpreter startup before it applies its profile. -The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing and print a fatal `landlock-run:` line. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. +The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. Foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged; a provider-argv spawn rejection becomes `SANDBOX_UNAVAILABLE` with its original detail in foreground and stamps `runnerFailed: true`, `denied: false` in background. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -78,7 +78,7 @@ The model sees the current effective file policy in the owner-derived `sandbox:p `BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. -`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. +`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` receives spawn failure out of band from stderr classification and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. @@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, structured runner classification (including partial-Landlock notice-only child outcomes, fatal evidence, outer-shell failures, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. @@ -172,20 +172,20 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. -- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown `SANDBOX_UNAVAILABLE`, the command never runs; fail closed, never open. +- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces as a direct spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. -- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. +- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. - **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message. - **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message. ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground re-throws structured `SANDBOX_UNAVAILABLE` with the matched fatal line; a background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. +- **How is a BROKEN sandbox told apart from a failing command?** A provider-argv spawn rejection proves the confined launch never started. After it starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground throws structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; a background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 0278b6302d..984a8d7185 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -36,9 +36,9 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs ``` -这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,通过提供方返回的包装 argv spawn。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 +这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接 spawn 提供方返回的已包装 argv。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 -配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段(命令 spawn 之前)抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 +配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 无法 spawn,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的运行时上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 @@ -60,17 +60,17 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。外层 shell 失败使用独立规则,并限定为解析后或配置的 argv0 与 shell 退出码 126/127。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,并将这些条目映射到一条内部致命规则;但命令缺失或不可执行时,仍归类为沙箱失败,绝不无约束地运行负载。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此 runner 缺失或不可执行时会通过 spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的 Bash 脚本必然要先启动解释器,再应用自身 profile。 -launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec,并打印一行致命的 `landlock-run:` 诊断。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 +launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测和 CLI flag,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI flag、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播;spawn 提供方 argv 遭拒时,前台会将其转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息,后台则盖章 `runnerFailed: true`、`denied: false`。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 @@ -78,7 +78,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes `BashExecRequest.sandboxPolicy` 是可选的完整按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 仍是公布已挂载执行器能否兑现该策略的能力事实,因此只有约束组合才暴露升级。seam 接受任何显式策略;工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 -`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `BashProcess` 为键;`onProcessDone()` 在 `done` 结算前分类 stderr 并给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。 +`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `BashProcess` 为键;`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败,并在 `done` 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。 当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 @@ -117,7 +117,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、致命证据、外层 shell 失败,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 +- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、spawn 层失败、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 @@ -172,20 +172,20 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 - **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 -- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 +- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会表现为直接 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 - **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 -- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 +- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 - **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。 - **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** 只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行;后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 +- **如何区分损坏的沙箱与失败的命令?** spawn 提供方 argv 遭拒证明受限启动从未开始。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f642a9d304..005bc22132 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -216,7 +216,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:40`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -235,7 +235,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) -Source: [`packages/bash/bash-sandbox/src/index.ts:34`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:35`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-cli-demo` @@ -1019,17 +1019,17 @@ export interface Config { /** * Override the runner argv; bwrap-shaped profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and - * probing; a broken runner then fails at execution and must be identifiable by - * {@link runnerFailureSignatures}. + * probing. A runner that starts but refuses its profile must be identifiable by + * {@link runnerFailureSignatures}; spawn rejection remains a consumer-owned + * infrastructure failure. */ runnerCommand?: string[] /** * Case-insensitive stderr substrings emitted when a configured * {@link runnerCommand} refuses its profile before executing the wrapped * command. Required and non-empty with `runnerCommand`; rejected without - * it. Missing/unexecutable runner errors are added automatically from - * `runnerCommand[0]`, while these signatures cover an executable runner's - * own failure dialect. + * it. Each entry is a non-empty, single-line, case-insensitive substring + * covering the executable runner's own failure dialect. */ runnerFailureSignatures?: string[] /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ @@ -1037,7 +1037,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:25`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 0949bb2279..8b5817065e 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -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 docs/core-data-structures/sandbox.md -sandbox.md: 8483104e0aab78779acb52b232a205280c93cd2b -sandbox.zh.md: b283095f1c847a4b2fca5a109e7894d6e3479839 +sandbox.md: 63a80084c5b6b25d55495d2d96daef73380993f6 +sandbox.zh.md: 447552e5c80b9cf3f4dd0b684c0607843a064448 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 8483104e0a..63a80084c5 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one entry for its own pre-exec refusal dialect. The provider maps those entries into one rule and adds a separate argv0-scoped rule for outer-shell missing or unexecutable failures. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. +The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing or unexecutable runner rejects through the spawn channel rather than a stderr rule; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case a matching structured rule carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A direct spawn rejection of the returned argv proves the confined launch never started and carries the same infrastructure meaning with the original error as detail. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy. Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index b283095f1c..447552e5c8 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一条条目。提供方会将这些条目映射到一条规则,并另加一条按 argv0 限定的外层 shell 规则,用于识别命令缺失或不可执行。这使得可执行的自定义 runner 拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 +面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此 runner 缺失或不可执行时会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的 runner 也可能在执行时 fail-closed,此时匹配到的结构化规则承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时若遭拒,即可证明受限启动从未开始;该拒绝具有相同的基础设施含义,并以原始错误作为详细信息。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。 提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml index 20940993d5..75c3218f4b 100644 --- a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml @@ -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 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md -0004-landlock-partial-notice-misclassified-child-failures.md: b896fc99a460b27e1b94b225543dded9fd31fbb1 -0004-landlock-partial-notice-misclassified-child-failures.zh.md: 5245f1d5bd859712df5d13a72cb81a2ae10063ef +0004-landlock-partial-notice-misclassified-child-failures.md: b3be42d221623c70796105a593127971660ebc2b +0004-landlock-partial-notice-misclassified-child-failures.zh.md: 7a057d4405fcc6733e9c86cbbbbfff9bf8012641 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md index b896fc99a4..b3be42d221 100644 --- a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md @@ -41,10 +41,10 @@ Stderr remains an in-band attribution channel. A confined child can deliberately ## Guardrails added - [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) carries optional allowed exit codes, case-insensitive per-line fatal signatures, and case-insensitive exact informational-line exclusions. -- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line, keeps bwrap/Seatbelt/custom behavior, and separates argv0-scoped outer-shell failures using exit 126/127. -- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) uses one evidence-returning classifier for foreground and background execution. Fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr. +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line while bwrap, Seatbelt, and custom runners remain signature-only. +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) directly spawns the provider argv, so a pre-start rejection uses the spawn-error channel instead of localized shell diagnostics. Settled foreground and background execution share one evidence-returning classifier; fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr. - Current [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) uses packaged ripgrep through `ctx.subprocess` and no longer consumes the sandboxed bash seam; the base reconciliation keeps that architecture unchanged. -- Deterministic tests use a POSIX fake partial-Landlock launcher to cover `true`, `false`, child exit 125, permission denial, real fatal diagnostics, and foreground/background parity. +- Deterministic tests use a POSIX fake partial-Landlock launcher to cover notice-only child exits 1, 2, and 125, ordinary child exits 126 and 127, gated fatal diagnostics, permission denial, and foreground/background parity. - The `examples/acp-agent` keyless snapshot runs direct bash `false` through a test-only partial-Landlock provider, keeping the product regression pinned independently of filesystem-search implementation choices. ## Lessons diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md index 5245f1d5bd..7a057d4405 100644 --- a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md @@ -41,10 +41,10 @@ stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的 ## 已添加的防护措施 - [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。 -- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,保留 bwrap/Seatbelt/自定义 runner 的行为,并以退出码 126/127 单独处理按 argv0 限定的外层 shell 失败。 -- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 为前台与后台执行使用同一个返回证据的分类器。致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。 +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,而 bwrap、Seatbelt 和自定义 runner 仍仅依据签名。 +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 直接 spawn 提供方 argv,因此启动前遭拒时使用 spawn 错误通道,而非本地化的 shell 诊断。已结算的前台与后台执行共用一个返回证据的分类器;致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。 - 当前 [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 通过 `ctx.subprocess` 运行打包的 ripgrep,不再消费沙箱化 bash seam;与新基线对齐时保持该架构不变。 -- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖 `true`、`false`、子进程退出码 125、权限拒绝、真实致命诊断,以及前台/后台一致性。 +- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖仅带通知的子进程退出码 1、2、125,普通子进程退出码 126、127,带门控的致命诊断、权限拒绝,以及前台/后台一致性。 - `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归。 ## 教训 diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 2ce25885f0..791addaf2d 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -1,7 +1,10 @@ import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' - -const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +import { + LAUNCHER_FAILURE_EXIT, + LAUNCHER_FATAL_PREFIX, + PARTIAL_ENFORCEMENT_NOTICE, +} from 'node-addon-landlock-run' /** Snapshot-only provider that reproduces an older-ABI Landlock launch. */ export default class PartialLandlockSandboxProvider extends SandboxProvider { @@ -10,16 +13,16 @@ export default class PartialLandlockSandboxProvider extends SandboxProvider { argv: [ 'bash', '-c', - `printf '%s\\n' '${NOTICE}' >&2; exec "$@"`, + `printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2; exec "$@"`, 'partial-landlock-run', ...argv, ], enforcement: 'partial', denialSignatures: ['permission denied'], runnerFailureRules: [{ - allowedExitCodes: [125], - fatalSignatures: ['landlock-run: '], - informationalLines: [NOTICE], + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: [LAUNCHER_FATAL_PREFIX], + informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], }], } } diff --git a/examples/package.json b/examples/package.json index 99d57bf4a6..ce5d4142e6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -100,6 +100,7 @@ "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", "@deepseek-ai/dsh-workflow-workerthread": "workspace:*", - "@deepseek-ai/dsh-workspace-context": "workspace:*" + "@deepseek-ai/dsh-workspace-context": "workspace:*", + "node-addon-landlock-run": "0.0.0-test.0" } } diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index e732ebdf31..5397c43e4f 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/README.i18n.yaml @@ -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 native/landlock-run/README.md -README.md: 284d5df764cf5a5205973696211aee2366d3b76e -README.zh.md: f369799cc8dcfb6de7c4b7b8c18857693d310418 +README.md: 483b14df7cb91fc6486f69efc1ddc560ab635ed5 +README.zh.md: a239889ec68a8d56bb901b17feaac7f7acbf66fa diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 284d5df764..483b14df7c 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -39,7 +39,7 @@ The public API is intentionally small: - `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal). - `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`. - `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied. -- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125): contract constants. +- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125), `LAUNCHER_FATAL_PREFIX`, and `PARTIAL_ENFORCEMENT_NOTICE`: contract constants. Every launcher failure uses 125, but a successfully exec'd child may also return 125, so failure attribution requires the fatal prefix too. The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md). diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index f369799cc8..a239889ec6 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -39,7 +39,7 @@ if (probe(launcher) !== 'unusable') { - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125):契约常量。 +- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125)、`LAUNCHER_FATAL_PREFIX` 和 `PARTIAL_ENFORCEMENT_NOTICE`:契约常量。所有 launcher 失败的退出码都是 125,但成功完成 exec 的子进程也可能返回 125,因此还必须有致命前缀,才能将结果归因为 launcher 失败。 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 diff --git a/native/landlock-run/docs/cli-contract.md b/native/landlock-run/docs/cli-contract.md index 57ab0f604c..b5422d1688 100644 --- a/native/landlock-run/docs/cli-contract.md +++ b/native/landlock-run/docs/cli-contract.md @@ -1,6 +1,6 @@ # CLI contract: landlock-run -This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs`); changing anything below requires a version bump for the whole package family and a note in the release notes. +This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs` and its protocol constants); changing anything below requires a version bump for the whole package family and a note in the release notes. ## Invocation grammar @@ -19,15 +19,15 @@ landlock-run --probe ## Exit codes -- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run (fail-closed; the one exception is `exec` itself failing after restriction, which by definition never ran the command either). -- Any other status: the wrapped command's own exit status, passed through unchanged. +- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run. +- After a successful `exec`, every child status is passed through unchanged, including 125. Consumers therefore require both status 125 and a `LAUNCHER_FATAL_PREFIX` line to attribute launcher failure. - `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise. ## Report lines - Probe success prints exactly one stdout line: `landlock: fully enforced` or `landlock: partially enforced (older ABI)`. The entry package's `probe()` maps these to `full`/`partial`; a non-zero probe exit maps to `unusable`. -- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` and proceeds — still confined for everything the kernel supports. -- Every fatal error prints one stderr line prefixed `landlock-run: ` before exiting `125`. +- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` (`PARTIAL_ENFORCEMENT_NOTICE`) and proceeds — still confined for everything the kernel supports. +- Every fatal error prints one stderr line prefixed `landlock-run: ` (`LAUNCHER_FATAL_PREFIX`) before exiting `125`. ## Confinement semantics diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 53de86122f..8cbd845e2a 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -21,11 +21,18 @@ import { fileURLToPath } from 'node:url' /** The launcher binary's file name inside each platform package's `bin/`. */ export const LAUNCHER_BIN = 'landlock-run' +/** Prefix on every launcher-owned fatal stderr line. */ +export const LAUNCHER_FATAL_PREFIX = 'landlock-run: ' + +/** Informational stderr line emitted before child execution under partial enforcement. */ +export const PARTIAL_ENFORCEMENT_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' + /** * The exit code for every launcher-level failure (usage error, unenforcing - * kernel, unopenable grant root, failed exec) — chosen because the wrapped - * command itself is unlikely to use it, so a consumer can tell launcher - * failures from command failures. Part of the CLI contract. + * kernel, unopenable grant root, failed exec). After a successful `exec`, the + * wrapped command may also return 125, so consumers require a matching + * {@link LAUNCHER_FATAL_PREFIX} diagnostic to attribute launcher failure. + * Part of the CLI contract. */ export const LAUNCHER_FAILURE_EXIT = 125 diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js index 2e2cfe8f17..ea006b649c 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/landlock-run/test/entry.test.js @@ -12,6 +12,8 @@ import path from 'node:path'; import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, + LAUNCHER_FATAL_PREFIX, + PARTIAL_ENFORCEMENT_NOTICE, grantArgs, launcherPath, probe, @@ -20,6 +22,8 @@ import { // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); assert.equal(LAUNCHER_FAILURE_EXIT, 125); +assert.equal(LAUNCHER_FATAL_PREFIX, 'landlock-run: '); +assert.equal(PARTIAL_ENFORCEMENT_NOTICE, 'landlock-run: partial enforcement (older Landlock ABI)'); // --- grantArgs: flag spelling, ordering, and empty grants --- assert.deepEqual(grantArgs({}), []); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 4ab0070e1c..b6ca14ab6c 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -19,6 +19,8 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { LAUNCHER_FAILURE_EXIT, + LAUNCHER_FATAL_PREFIX, + PARTIAL_ENFORCEMENT_NOTICE, grantArgs, launcherPath, probe, @@ -43,6 +45,7 @@ const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8' { const noCommand = run([]); assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT); + assert.ok(noCommand.stderr.startsWith(LAUNCHER_FATAL_PREFIX)); assert.match(noCommand.stderr, /usage error: missing `-- \.\.\.` command/); const unknownFlag = run(['--bogus', '--', 'true']); @@ -75,6 +78,7 @@ if (enforcement === 'unusable') { console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock'); process.exit(0); } +const expectedNotice = enforcement === 'partial' ? `${PARTIAL_ENFORCEMENT_NOTICE}\n` : ''; { const probeRun = run(['--probe']); assert.equal(probeRun.status, 0); @@ -86,9 +90,14 @@ if (enforcement === 'unusable') { const echo = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo confined-ok']); assert.equal(echo.status, 0, echo.stderr); assert.equal(echo.stdout, 'confined-ok\n'); + assert.equal(echo.stderr, expectedNotice); const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']); assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged'); + + const child125 = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `exit ${LAUNCHER_FAILURE_EXIT}`]); + assert.equal(child125.status, LAUNCHER_FAILURE_EXIT, 'a wrapped child may itself return the launcher failure status'); + assert.equal(child125.stderr, expectedNotice); } // --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec --- @@ -120,6 +129,7 @@ if (enforcement === 'unusable') { const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`); const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]); assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT); + assert.ok(badGrant.stderr.startsWith(LAUNCHER_FATAL_PREFIX)); assert.match(badGrant.stderr, /cannot open rule path/); assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails'); } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..6ad655fcb6 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,10 +1,11 @@ /** * Local implementation of the bash executor seam over the subprocess - * seam. Each command runs as `bash -c` in a managed process group spawned - * through `ctx.subprocess`; this executor owns command defaulting, deadlines - * and cause classification, the model-friendly terminal environment, and the - * model-facing stdout/stderr merge for background reads. Execution policy - * belongs in `tools/pre-execute` or a sandboxing executor. + * 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 + * stdout/stderr merge for background reads. Execution policy belongs in + * `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ @@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + /** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + private spawnSpec( + spec: BashExecSpec, + argv: readonly string[], + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: ['bash', '-c', spec.command], + argv, cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise { + return this.runArgv(spec, ['bash', '-c', spec.command]) + } + + /** + * Run an explicit argv with the foreground lifecycle, environment, output, + * timeout, and cancellation semantics of this executor. Subclasses use this + * after replacing the public command's shell argv at an execution boundary. + * @param spec - resolved execution settings and caller-owned command metadata. + * @param argv - exact executable and arguments to hand to `ctx.subprocess`. + * @returns the settled foreground result with collected output and cause facts. + */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise { // 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, argv, spec.stdoutMaxBytes, d.signal)) const outcome = await handle.done const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, ['bash', '-c', spec.command]) + } + + /** + * Start an explicit argv with the background lifecycle, environment, output, + * cancellation, and process-tree ownership semantics of this executor. + * Subclasses use this after replacing the public command's shell argv at an + * execution boundary. + * @param spec - resolved execution settings and caller-owned command metadata. + * @param argv - exact executable and arguments to hand to `ctx.subprocess`. + * @returns the live background handle; spawn rejection settles it as killed. + */ + 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, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -221,7 +252,7 @@ export class LocalBashExecutor extends BashExecutor { // 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, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -261,8 +292,9 @@ export class LocalBashExecutor extends BashExecutor { * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnError - the original spawn rejection; absent after a process successfully started. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnError?: unknown): void {} } export default LocalBashExecutor diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index e1f518e8f1..df83d16576 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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: 851bedea3589efe164cf49b7e6db1a5f3d64df24 -README.zh.md: f4eb13dd11f8a22dd0a0d594a615567571b1ccde +README.md: c1a6d6ae19f6d5ec95087d32c7d5d18d5f28b7f5 +README.zh.md: a6bcc812e35c842c7d3880a41814a872f3dbca14 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 851bedea35..c1a6d6ae19 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -4,9 +4,9 @@ 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. -The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. +The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal. -Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. +Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. | Mode | File effects | |---|---| @@ -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 failures are sandbox failures, never command failures.** Foreground and background execution use the same structured classifier: 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`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. +- **Runner failures are sandbox failures, never command failures.** A rejected spawn of the provider argv is out-of-band proof that the confined launch never started: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. 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. - **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/). @@ -72,7 +72,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). For an execution-time runner failure, this backend supplies the matched fatal stderr line as its detail and preserves the original stderr collection. +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 provider-argv spawn rejection supplies the original spawn error as detail; a settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index f4eb13dd11..a6bcc812e3 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -4,9 +4,9 @@ 这是使用沙箱能力的 [`@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` 能力并添加升权字段。 -包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 +包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。 -每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 | 模式 | 文件影响 | |---|---| @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台与后台执行使用同一个结构化分类器:先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** spawn 提供方 argv 遭拒,是受限启动从未开始的带外证据:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 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/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。spawn 提供方 argv 遭拒时,以原始 spawn 错误作为详细信息;已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index 93363a1b15..fc74dab8e3 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -1,5 +1,5 @@ /** - * Internal shell-quoting and sandbox-result classification helpers. + * Internal sandbox-result classification helpers. * * @module @deepseek-ai/dsh-bash-sandbox/helpers */ @@ -13,15 +13,6 @@ interface RunnerFailureMatch { detail: string } -/** - * Quote one string as a single-quoted POSIX shell word. - * @param text - raw argv element to preserve through the outer shell parse. - * @returns the quoted shell word. - */ -export function shellQuote(text: string): string { - return `'${text.replaceAll("'", String.raw`'\''`)}'` -} - /** * Classify a failed run against the selected backend's denial dialect. * @param result - settled foreground run. diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 854789f428..142ab80b1f 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -12,6 +12,7 @@ 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, @@ -22,7 +23,7 @@ import type { import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' -import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts' +import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts' /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — @@ -90,7 +91,14 @@ export class SandboxBashExecutor extends LocalBashExecutor { return { ...result, sandbox: { mode, denied: false } } } const confined = this.confine(spec.command, { ...policy, mode }) - const result = await super.run({ ...spec, command: confined.command }) + 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() + throw new SandboxUnavailableError(mode, String(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) @@ -106,7 +114,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { if (mode === 'danger-full-access') return super.start(spec) // Install facts synchronously; promise settlement cannot run before start() returns. const confined = this.confine(spec.command, { ...policy, mode }) - const proc = super.start({ ...spec, command: confined.command }) + const proc = this.startArgv(spec, confined.argv) const { enforcement, denialSignatures, runnerFailureRules } = confined this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules }) return proc @@ -116,12 +124,14 @@ export class SandboxBashExecutor extends LocalBashExecutor { * 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): void { + protected override onProcessDone(proc: BashProcess, stderr: string, spawnError?: unknown): void { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // Runner failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnError !== undefined + || classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), @@ -129,30 +139,19 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr) + super.onProcessDone(proc, stderr, spawnError) } /** - * Wrap one shell command via the `ctx.sandbox` provider: hand over the - * exact `['bash', '-c', command]` argv this executor would spawn, get back - * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns - * `exec`s into the runner, so no extra shell lingers). Provider errors - * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. + * Wrap one shell command via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param command - shell source for the confined inner `bash -c`. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. */ - private confine(command: string, policy: SandboxPolicy): { - command: string - enforcement: SandboxEnforcement - denialSignatures: readonly string[] - runnerFailureRules: readonly RunnerFailureRule[] - } { - const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) - return { - command: `exec ${confined.argv.map(shellQuote).join(' ')}`, - enforcement: confined.enforcement, - denialSignatures: confined.denialSignatures, - runnerFailureRules: confined.runnerFailureRules, - } + private confine(command: string, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(['bash', '-c', command], policy) } } diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 9b63c4d1e6..d3f124151e 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -1,6 +1,6 @@ /** * Deterministic real-process proofs for runner classification: the real local - * provider and sandbox bash executor exercise an outer-shell launch failure + * provider and sandbox bash executor exercise direct runner-spawn failures * and a POSIX fake Landlock launcher that prints its notice before exec. */ @@ -9,14 +9,18 @@ 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, + LAUNCHER_FATAL_PREFIX, + PARTIAL_ENFORCEMENT_NOTICE, +} from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' -const FATAL = 'landlock-run: landlock ruleset error: Invalid argument' +const FATAL = `${LAUNCHER_FATAL_PREFIX}landlock ruleset error: Invalid argument` const contexts: Context[] = [] const tempDirs: string[] = [] @@ -27,26 +31,26 @@ afterEach(async () => { }) /** Write a fake native launcher that reports partial enforcement, then execs or fails. */ -async function fakeLauncher(fatal: boolean): Promise { +async function fakeLauncher(fatalExit?: number): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-')) tempDirs.push(dir) const launcher = join(dir, 'landlock-run') - const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : '' + const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n` await writeFile(launcher, `#!/bin/sh while [ "$#" -gt 0 ]; do case "$1" in --ro|--rw) shift 2 ;; --) shift; break ;; - *) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;; + *) printf '%s\\n' '${LAUNCHER_FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;; esac done -printf '%s\\n' '${NOTICE}' >&2 +printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2 ${fatalBranch}exec "$@" `, { mode: 0o755 }) return launcher } -async function setup(fatal = false): Promise { +async function setup(fatalExit?: number): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LocalSandboxProvider, {}) @@ -55,7 +59,7 @@ async function setup(fatal = false): Promise { platform: 'linux', probeBwrap: () => false, probeLandlock: () => 'partial', - landlockLauncher: await fakeLauncher(fatal), + landlockLauncher: await fakeLauncher(fatalExit), } await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) await ctx.plugin(LocalSubprocessService) @@ -63,60 +67,96 @@ async function setup(fatal = false): Promise { return ctx.bash as SandboxBashExecutor } -describe('partial Landlock runner-failure classification', () => { - it.skipIf(process.platform === 'win32')('classifies a genuinely missing configured runner through the outer bash exec rule', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-missing-sandbox-runner-')) - tempDirs.push(dir) - const missingRunner = join(dir, 'missing-runner') - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(LocalSandboxProvider, { - runnerCommand: [missingRunner], - runnerFailureSignatures: ['configured-runner: fatal'], - }) - await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) - await ctx.plugin(LocalSubprocessService) - await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) +async function setupConfiguredRunner(runner: string): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider, { + runnerCommand: [runner], + runnerFailureSignatures: ['configured-runner: fatal'], + }) + await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) + return ctx.bash as SandboxBashExecutor +} - const error = await ctx.bash.run(ctx.bash.resolve({ command: 'true' })).catch((value: unknown) => value) +describe('partial Landlock runner-failure classification', () => { + it.each(['missing', 'unexecutable'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-')) + tempDirs.push(dir) + const runner = join(dir, `${kind}-runner`) + if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 }) + const bash = await setupConfiguredRunner(runner) + + const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain(missingRunner) + expect((error as Error).message).toContain(runner) + + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + expect(task.status).toBe('killed') + expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`) + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) + const accounting = (bash as unknown as { processFacts: Map }).processFacts + expect(accounting.size).toBe(0) }) - it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => { - const bash = await setup() - for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) { - const result = await bash.run(bash.resolve({ command })) + it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])( + 'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line', + async (exitCode) => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) expect(result.exitCode).toBe(exitCode) - expect(result.stderr.text).toBe(`${NOTICE}\n`) + expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) - } + }, + ) + + it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + }) + + it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => { + const bash = await setup(exitCode) + const result = await bash.run(bash.resolve({ command: 'true' })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n${FATAL}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => { - const bash = await setup(true) + const bash = await setup(LAUNCHER_FAILURE_EXIT) const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toContain(`Runner failure: ${FATAL}`) - expect((error as Error).message).not.toContain(NOTICE) + expect((error as Error).message).not.toContain(PARTIAL_ENFORCEMENT_NOTICE) }) it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => { const bash = await setup() const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) - expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`) + expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\nchild: Permission denied\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }) it('applies the same evidence rule to notice-only background exits', async () => { const bash = await setup() - for (const command of ['false', 'exit 125']) { + for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) { const task = bash.start(bash.resolve({ command })) await task.done expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) - expect(task.readOutput().delta).toContain(NOTICE) + expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE) } }) @@ -125,11 +165,11 @@ describe('partial Landlock runner-failure classification', () => { const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) await task.done expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) - expect(task.readOutput().delta).toContain(NOTICE) + expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE) }) it('makes a background fatal line outrank denial text after the notice', async () => { - const bash = await setup(true) + const bash = await setup(LAUNCHER_FAILURE_EXIT) const task = bash.start(bash.resolve({ command: 'true' })) await task.done expect(task.sandbox).toEqual({ @@ -139,7 +179,7 @@ describe('partial Landlock runner-failure classification', () => { runnerFailed: true, }) const output = task.readOutput().delta - expect(output).toContain(NOTICE) + expect(output).toContain(PARTIAL_ENFORCEMENT_NOTICE) expect(output).toContain(FATAL) }) }) diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 5660ae069b..10943b167a 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -5,7 +5,7 @@ * the Unix denial signature used by the classifier without requiring a real sandbox runner. */ -import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -16,7 +16,7 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' +import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) @@ -90,15 +90,49 @@ describe('the provider hand-off', () => { }]) }) - it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => { - // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix: - // the sentinel only prints if the executor spawned the WRAPPED argv. - const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) + it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => { + const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"'] + const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' })) expect(result.stdout.text).toBe('1') + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) }) + it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-')) + const hook = join(dir, 'hook.sh') + const order = join(dir, 'order.txt') + writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n') + const runnerScript = [ + 'const { appendFileSync } = require("node:fs");', + 'const { spawnSync } = require("node:child_process");', + 'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");', + 'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });', + 'process.exit(child.status ?? 125);', + ].join('') + const { bash } = await setup({}, argv => ({ + argv: [process.execPath, '-e', runnerScript, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + + try { + const result = await bash.run(bash.resolve({ + command: 'true', + env: { BASH_ENV: hook }, + dshEnv: { DSH_ORDER_FILE: order }, + })) + expect(result.exitCode).toBe(0) + expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) @@ -120,9 +154,6 @@ describe('the provider hand-off', () => { expect(calls).toHaveLength(2) }) - it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => { - expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`) - }) }) describe('fail closed', () => { @@ -132,6 +163,14 @@ describe('fail closed', () => { await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) expect(() => bash.start(spec)).toThrow(SandboxUnavailableError) }) + + it('preserves an already-aborted foreground call as cancellation', async () => { + const { bash } = await setup() + const controller = new AbortController() + const reason = new Error('caller cancelled before spawn') + controller.abort(reason) + await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason) + }) }) describe('danger-full-access', () => { @@ -252,19 +291,6 @@ describe('classifyRunnerFailure', () => { expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal }) }) - it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => { - const rules = [{ - allowedExitCodes: [126, 127], - fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'], - }] - expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail) - .toBe('bash: /Opt/Runners/bwrap: No such file or directory') - expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail) - .toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND') - expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined() - expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined() - }) - it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => { const notice = 'landlock-run: partial enforcement (older Landlock ABI)' const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }] @@ -297,6 +323,18 @@ describe('classifyRunnerFailure', () => { }) describe('result facts', () => { + it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => { + const { bash } = await setup({}, argv => ({ + argv: ['env', ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) + expect(result.exitCode).toBe(exitCode) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }) + it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => { const { bash } = await setup() const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked') @@ -324,7 +362,12 @@ describe('background sandbox facts', () => { expect(task.status).toBe('killed') expect(task.readOutput().delta).toContain('spawn failed:') - expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) const accounting = (bash as unknown as { processFacts: Map }).processFacts expect(accounting.size).toBe(0) }) diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 62b1569ee7..c03e407986 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug expect(existsSync(join(outside, 'denied.txt'))).toBe(false) }) + it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => { + const workdir = await tempDir(homedir()) + const outside = await tempDir(homedir()) + const hook = join(workdir, 'bash-env-hook.sh') + const insideProbe = join(workdir, 'hook-ran.txt') + const outsideProbe = join(outside, 'escaped.txt') + await writeFile(hook, [ + 'printf hook > "$DSH_BASH_ENV_INSIDE"', + 'printf escaped > "$DSH_BASH_ENV_OUTSIDE"', + '', + ].join('\n')) + const bash = await sandboxedBash(workdir, 'workspace-write') + + await bash.run(bash.resolve({ + command: 'true', + env: { BASH_ENV: hook }, + dshEnv: { + DSH_BASH_ENV_INSIDE: insideProbe, + DSH_BASH_ENV_OUTSIDE: outsideProbe, + }, + })) + + expect(readFileSync(insideProbe, 'utf8')).toBe('hook') + expect(existsSync(outsideProbe)).toBe(false) + }) + it('classifies a background denial once the task settles', async () => { const workdir = await tempDir(homedir()) const bash = await sandboxedBash(workdir, 'read-only') diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 2f3b8d24d0..c95f10cbe4 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -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/sandbox/sandbox-local/README.md -README.md: d53f138a9a752a6ea62992f269bf76680a24d49f -README.zh.md: b0f599af04f101712029585677b7a25fc11e3908 +README.md: f7dd5619fa69ae226def78415a91d51ba99e0a65 +README.zh.md: 9477e8b99df54f96c364413ebbf8f99cfff4b2f9 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index d53f138a9a..f7dd5619fa 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -8,7 +8,7 @@ The package root exports the default and named `LocalSandboxProvider` plugin, `C Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. -Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Separate outer-shell rules use the resolved/configured argv0 with missing or unexecutable wording and exit 126/127. `runnerCommand` remains the operator-facing config key for a custom runner's fatal signatures and skips probes. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. +Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. @@ -37,4 +37,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. -- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index b0f599af04..9477e8b99d 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -8,7 +8,7 @@ 不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 -策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。独立的外层 shell 规则使用解析后或配置的 argv0、缺失或不可执行诊断文本,以及退出码 126/127。`runnerCommand` 仍是面向运维人员的配置键,用于指定自定义 runner 的致命签名,并会跳过探测。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 @@ -37,4 +37,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 -- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index f8ccdc0c9d..719bdca01f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -8,8 +8,9 @@ import { spawnSync } from 'node:child_process' import { - LAUNCHER_BIN, + LAUNCHER_FATAL_PREFIX, LAUNCHER_FAILURE_EXIT, + PARTIAL_ENFORCEMENT_NOTICE, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, } from 'node-addon-landlock-run' @@ -25,17 +26,17 @@ export interface Config { /** * Override the runner argv; bwrap-shaped profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and - * probing; a broken runner then fails at execution and must be identifiable by - * {@link runnerFailureSignatures}. + * probing. A runner that starts but refuses its profile must be identifiable by + * {@link runnerFailureSignatures}; spawn rejection remains a consumer-owned + * infrastructure failure. */ runnerCommand?: string[] /** * Case-insensitive stderr substrings emitted when a configured * {@link runnerCommand} refuses its profile before executing the wrapped * command. Required and non-empty with `runnerCommand`; rejected without - * it. Missing/unexecutable runner errors are added automatically from - * `runnerCommand[0]`, while these signatures cover an executable runner's - * own failure dialect. + * it. Each entry is a non-empty, single-line, case-insensitive substring + * covering the executable runner's own failure dialect. */ runnerFailureSignatures?: string[] /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ @@ -147,37 +148,21 @@ const DENIAL_SIGNATURES = { } as const satisfies Record /** - * Runner-owned fatal diagnostics. Landlock's launcher contract reserves exit - * 125 for launcher failure and emits the partial-ABI notice before successful - * child execution, so both the status gate and the exact exclusion are needed. + * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus + * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit + * 1 but its public contract does not reserve that status, while sandbox-exec + * publishes no launcher-failure status; those backends remain signature-only. */ const RUNNER_FAILURE_RULES = { bwrap: [{ fatalSignatures: ['bwrap: '] }], landlock: [{ allowedExitCodes: [LAUNCHER_FAILURE_EXIT], - fatalSignatures: [`${LAUNCHER_BIN}: `], - informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], + fatalSignatures: [LAUNCHER_FATAL_PREFIX], + informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], } as const satisfies Record -/** - * Failure shapes emitted by the outer `bash -c 'exec ...'` before the runner - * starts. Shells vary between 126 and 127 for a missing path containing `/`, - * but keep the configured/resolved argv0 and missing/unexecutable wording. - */ -function outerShellFailureRules(argv0: string): readonly RunnerFailureRule[] { - return [{ - allowedExitCodes: [126, 127], - fatalSignatures: [ - `exec: ${argv0}: not found`, - `${argv0}: No such file or directory`, - `${argv0}: Permission denied`, - `exec: ${argv0}: cannot execute`, - ], - }] -} - /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless * apart from the cached chain verdict — it spawns nothing but the one-time @@ -213,8 +198,8 @@ export class LocalSandboxProvider extends SandboxProvider { if (runner.length > 0 && runnerFailureSignatures.length === 0) { throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry') } - if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) { - throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty') + if (runnerFailureSignatures.some(signature => signature.trim().length === 0 || /[\r\n]/u.test(signature))) { + throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings') } this.runnerCommand = runner.length > 0 ? runner : undefined this.configuredRunnerFailureSignatures = runnerFailureSignatures @@ -235,30 +220,20 @@ export class LocalSandboxProvider extends SandboxProvider { */ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { if (this.runnerCommand !== undefined) { - const argv0 = this.runnerCommand[0] as string return { argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv], enforcement: 'full', denialSignatures: DENIAL_SIGNATURES.runnerCommand, - // Preserve the operator-facing signature config as one internal rule; - // outer-shell launch failures remain a separate, argv0-scoped rule. - runnerFailureRules: [ - ...outerShellFailureRules(argv0), - { fatalSignatures: this.configuredRunnerFailureSignatures }, - ], + runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }], } } const selected = this.selectRunner(policy.mode) const runnerArgv = this.runnerArgv(selected.runner, policy) - const argv0 = runnerArgv[0] as string return { argv: [...runnerArgv, '--', ...argv], enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], - runnerFailureRules: [ - ...outerShellFailureRules(argv0), - ...RUNNER_FAILURE_RULES[selected.runner], - ], + runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner], } } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 94078facc6..13f860a94a 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,6 +12,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { + LAUNCHER_FAILURE_EXIT, + LAUNCHER_FATAL_PREFIX, + PARTIAL_ENFORCEMENT_NOTICE, +} from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { @@ -23,19 +28,6 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } -/** Expected argv0-scoped rule for failures in bash's outer `exec`. */ -function outerShellRule(argv0: string) { - return { - allowedExitCodes: [126, 127], - fatalSignatures: [ - `exec: ${argv0}: not found`, - `${argv0}: No such file or directory`, - `${argv0}: Permission denied`, - `exec: ${argv0}: cannot execute`, - ], - } -} - async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) { const ctx = new Context() await ctx.plugin(LocalSandboxProvider, config) @@ -48,7 +40,7 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') - writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 }) + writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) return launcher } @@ -124,14 +116,7 @@ describe('runnerCommand config', () => { // An operator runner's kernel mechanism is unknown: both Linux // file-denial dialects, never bare EPERM. denialSignatures: ['read-only file system', 'permission denied'], - // The runner's own dialect is unknown, but the consumer re-joins the - // wrap through an outer `bash -c 'exec …'` — a missing or - // unexecutable runner fails with the OUTER shell's argv0-scoped - // shapes, and those classify as sandbox failures like any rung. - runnerFailureRules: [ - outerShellRule('fake-runner'), - { fatalSignatures: ['fake-runner: profile rejected'] }, - ], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: profile rejected'] }], }) expect(probeBwrap).not.toHaveBeenCalled() expect(probeLandlock).not.toHaveBeenCalled() @@ -157,11 +142,14 @@ describe('runnerCommand config', () => { ) }) - it('rejects blank configured-runner failure signatures', async () => { - await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow( - 'runnerFailureSignatures entries must be non-empty', - ) - }) + it.each([' ', 'fatal\ncontinued', 'fatal\rcontinued'])( + 'rejects an unusable configured-runner failure signature %j', + async (signature) => { + await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [signature] })).rejects.toThrow( + 'runnerFailureSignatures entries must be non-empty single-line strings', + ) + }, + ) }) describe('the platform chains', () => { @@ -174,10 +162,7 @@ describe('the platform chains', () => { argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['read-only file system'], - runnerFailureRules: [ - outerShellRule('bwrap'), - { fatalSignatures: ['bwrap: '] }, - ], + runnerFailureRules: [{ fatalSignatures: ['bwrap: '] }], }) expect(probeLandlock).not.toHaveBeenCalled() }) @@ -192,14 +177,11 @@ describe('the platform chains', () => { argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['permission denied'], - runnerFailureRules: [ - outerShellRule(launcher), - { - allowedExitCodes: [125], - fatalSignatures: ['landlock-run: '], - informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], - }, - ], + runnerFailureRules: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: [LAUNCHER_FATAL_PREFIX], + informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], + }], }) expect(probeLandlock).toHaveBeenCalledWith(launcher) }) @@ -215,10 +197,7 @@ describe('the platform chains', () => { argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureRules: [ - outerShellRule('sandbox-exec'), - { fatalSignatures: ['sandbox-exec: '] }, - ], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) expect(probeSeatbelt).not.toHaveBeenCalled() }) @@ -335,7 +314,7 @@ describe('the default landlock probe (launcher CLI contract)', () => { it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') - writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 }) + writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) @@ -384,10 +363,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureRules: [ - outerShellRule(exec), - { fatalSignatures: ['sandbox-exec: '] }, - ], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26184115cc..252e5b3637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -848,6 +848,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:* version: link:../packages/context/workspace-context + node-addon-landlock-run: + specifier: 0.0.0-test.0 + version: 0.0.0-test.0 packages/acp/acp: dependencies: From 319376ef1c6859d7f828547f8127a5763f355b6c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 12:16:21 +0800 Subject: [PATCH 059/108] fix(sandbox): preserve valueless spawn failures (round 2) --- packages/bash/bash-local/src/index.ts | 9 +++--- packages/bash/bash-sandbox/src/index.ts | 6 ++-- .../bash/bash-sandbox/tests/sandbox.spec.ts | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6ad655fcb6..4ed49be02a 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -247,12 +247,12 @@ export class LocalBashExecutor 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, error) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -292,9 +292,10 @@ export class LocalBashExecutor extends BashExecutor { * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. - * @param _spawnError - the original spawn rejection; absent after a process successfully started. + * @param _spawnFailed - whether the subprocess promise rejected before a process started. + * @param _spawnError - the original spawn rejection reason, which may itself be undefined. */ - protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnError?: unknown): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } export default LocalBashExecutor diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 142ab80b1f..8de2369a26 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -124,13 +124,13 @@ export class SandboxBashExecutor extends LocalBashExecutor { * 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, spawnError?: unknown): void { + 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 = spawnError !== undefined + const runnerFailed = spawnFailed || classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, @@ -139,7 +139,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr, spawnError) + super.onProcessDone(proc, stderr, spawnFailed, spawnError) } /** diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 10943b167a..6328d96db3 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -16,6 +16,7 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -372,6 +373,36 @@ describe('background sandbox facts', () => { expect(accounting.size).toBe(0) }) + it('classifies a spawn rejection whose reason is undefined', async () => { + const { ctx, bash } = await setup() + const emptyReader: SubprocessOutputReader = { + readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), + } + vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ + pid: -1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: emptyReader, stderr: emptyReader }, + // Arbitrary subprocess providers can reject without a value; that edge is the point of this test. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + done: Promise.reject(undefined), + terminate: vi.fn(), + waitForExit: async () => true, + } satisfies SubprocessHandle) + + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + + expect(task.readOutput().delta).toContain('spawn failed: undefined') + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) + }) + it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => { const { bash } = await setup() const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' })) From f91aedd074816bb5c2d8bf8cefdea0f449a2c713 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 13:37:33 +0800 Subject: [PATCH 060/108] refactor(sandbox): defer native protocol exports (round 3) --- docs/config-catalog.md | 2 +- .../fixtures/partial-landlock-sandbox.ts | 15 ++++------ examples/package.json | 3 +- native/landlock-run/README.i18n.yaml | 4 +-- native/landlock-run/README.md | 2 +- native/landlock-run/README.zh.md | 2 +- native/landlock-run/docs/cli-contract.md | 8 ++--- .../landlock-run/packages/entry/src/index.ts | 12 ++------ native/landlock-run/test/entry.test.js | 4 --- native/landlock-run/test/launcher.test.js | 10 +++---- .../tests/partial-landlock.spec.ts | 30 +++++++++---------- packages/sandbox/sandbox-local/src/index.ts | 7 ++--- .../sandbox/sandbox-local/tests/local.spec.ts | 10 ++----- pnpm-lock.yaml | 4 --- 14 files changed, 44 insertions(+), 69 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 005bc22132..12b6425f1e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1037,7 +1037,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:25`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 791addaf2d..2ce25885f0 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -1,10 +1,7 @@ import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' -import { - LAUNCHER_FAILURE_EXIT, - LAUNCHER_FATAL_PREFIX, - PARTIAL_ENFORCEMENT_NOTICE, -} from 'node-addon-landlock-run' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' /** Snapshot-only provider that reproduces an older-ABI Landlock launch. */ export default class PartialLandlockSandboxProvider extends SandboxProvider { @@ -13,16 +10,16 @@ export default class PartialLandlockSandboxProvider extends SandboxProvider { argv: [ 'bash', '-c', - `printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2; exec "$@"`, + `printf '%s\\n' '${NOTICE}' >&2; exec "$@"`, 'partial-landlock-run', ...argv, ], enforcement: 'partial', denialSignatures: ['permission denied'], runnerFailureRules: [{ - allowedExitCodes: [LAUNCHER_FAILURE_EXIT], - fatalSignatures: [LAUNCHER_FATAL_PREFIX], - informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: [NOTICE], }], } } diff --git a/examples/package.json b/examples/package.json index ce5d4142e6..99d57bf4a6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -100,7 +100,6 @@ "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", "@deepseek-ai/dsh-workflow-workerthread": "workspace:*", - "@deepseek-ai/dsh-workspace-context": "workspace:*", - "node-addon-landlock-run": "0.0.0-test.0" + "@deepseek-ai/dsh-workspace-context": "workspace:*" } } diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index 5397c43e4f..e7eb1fb137 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/README.i18n.yaml @@ -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 native/landlock-run/README.md -README.md: 483b14df7cb91fc6486f69efc1ddc560ab635ed5 -README.zh.md: a239889ec68a8d56bb901b17feaac7f7acbf66fa +README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b +README.zh.md: c93c33b843e3afc15b1890ce14edf436e051592c diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 483b14df7c..19cc18830b 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -39,7 +39,7 @@ The public API is intentionally small: - `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal). - `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`. - `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied. -- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125), `LAUNCHER_FATAL_PREFIX`, and `PARTIAL_ENFORCEMENT_NOTICE`: contract constants. Every launcher failure uses 125, but a successfully exec'd child may also return 125, so failure attribution requires the fatal prefix too. +- `LAUNCHER_BIN` and `LAUNCHER_FAILURE_EXIT` (125): contract constants. A successfully exec'd child may also return 125, so consumers need the fatal diagnostic as well as the status to attribute launcher failure. The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md). diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index a239889ec6..c93c33b843 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -39,7 +39,7 @@ if (probe(launcher) !== 'unusable') { - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125)、`LAUNCHER_FATAL_PREFIX` 和 `PARTIAL_ENFORCEMENT_NOTICE`:契约常量。所有 launcher 失败的退出码都是 125,但成功完成 exec 的子进程也可能返回 125,因此还必须有致命前缀,才能将结果归因为 launcher 失败。 +- `LAUNCHER_BIN` 和 `LAUNCHER_FAILURE_EXIT`(125):契约常量。成功完成 exec 的子进程也可能返回 125,因此消费者必须同时看到致命诊断和该状态,才能将结果归因为 launcher 失败。 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 diff --git a/native/landlock-run/docs/cli-contract.md b/native/landlock-run/docs/cli-contract.md index b5422d1688..ad9a002d8f 100644 --- a/native/landlock-run/docs/cli-contract.md +++ b/native/landlock-run/docs/cli-contract.md @@ -1,6 +1,6 @@ # CLI contract: landlock-run -This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs` and its protocol constants); changing anything below requires a version bump for the whole package family and a note in the release notes. +This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it through the entry package (`launcherPath`/`probe`/`grantArgs`) and the launcher protocol; changing anything below requires a version bump for the whole package family and a note in the release notes. ## Invocation grammar @@ -20,14 +20,14 @@ landlock-run --probe ## Exit codes - `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run. -- After a successful `exec`, every child status is passed through unchanged, including 125. Consumers therefore require both status 125 and a `LAUNCHER_FATAL_PREFIX` line to attribute launcher failure. +- After a successful `exec`, every child status is passed through unchanged, including 125. Consumers therefore require both status 125 and a `landlock-run: ` fatal line to attribute launcher failure. - `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise. ## Report lines - Probe success prints exactly one stdout line: `landlock: fully enforced` or `landlock: partially enforced (older ABI)`. The entry package's `probe()` maps these to `full`/`partial`; a non-zero probe exit maps to `unusable`. -- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` (`PARTIAL_ENFORCEMENT_NOTICE`) and proceeds — still confined for everything the kernel supports. -- Every fatal error prints one stderr line prefixed `landlock-run: ` (`LAUNCHER_FATAL_PREFIX`) before exiting `125`. +- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` and proceeds — still confined for everything the kernel supports. +- Every fatal error prints one stderr line prefixed `landlock-run: ` before exiting `125`. ## Confinement semantics diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 8cbd845e2a..7a4349a5ca 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -21,18 +21,12 @@ import { fileURLToPath } from 'node:url' /** The launcher binary's file name inside each platform package's `bin/`. */ export const LAUNCHER_BIN = 'landlock-run' -/** Prefix on every launcher-owned fatal stderr line. */ -export const LAUNCHER_FATAL_PREFIX = 'landlock-run: ' - -/** Informational stderr line emitted before child execution under partial enforcement. */ -export const PARTIAL_ENFORCEMENT_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' - /** * The exit code for every launcher-level failure (usage error, unenforcing * kernel, unopenable grant root, failed exec). After a successful `exec`, the - * wrapped command may also return 125, so consumers require a matching - * {@link LAUNCHER_FATAL_PREFIX} diagnostic to attribute launcher failure. - * Part of the CLI contract. + * wrapped command may also return 125, so consumers also require a matching + * launcher-owned fatal diagnostic to attribute launcher failure. Part of the + * CLI contract. */ export const LAUNCHER_FAILURE_EXIT = 125 diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js index ea006b649c..2e2cfe8f17 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/landlock-run/test/entry.test.js @@ -12,8 +12,6 @@ import path from 'node:path'; import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, - LAUNCHER_FATAL_PREFIX, - PARTIAL_ENFORCEMENT_NOTICE, grantArgs, launcherPath, probe, @@ -22,8 +20,6 @@ import { // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); assert.equal(LAUNCHER_FAILURE_EXIT, 125); -assert.equal(LAUNCHER_FATAL_PREFIX, 'landlock-run: '); -assert.equal(PARTIAL_ENFORCEMENT_NOTICE, 'landlock-run: partial enforcement (older Landlock ABI)'); // --- grantArgs: flag spelling, ordering, and empty grants --- assert.deepEqual(grantArgs({}), []); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index b6ca14ab6c..55385d2156 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -19,13 +19,13 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { LAUNCHER_FAILURE_EXIT, - LAUNCHER_FATAL_PREFIX, - PARTIAL_ENFORCEMENT_NOTICE, grantArgs, launcherPath, probe, } from 'node-addon-landlock-run'; +const FATAL_PREFIX = 'landlock-run: '; +const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; if (process.platform !== 'linux') { @@ -45,7 +45,7 @@ const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8' { const noCommand = run([]); assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT); - assert.ok(noCommand.stderr.startsWith(LAUNCHER_FATAL_PREFIX)); + assert.ok(noCommand.stderr.startsWith(FATAL_PREFIX)); assert.match(noCommand.stderr, /usage error: missing `-- \.\.\.` command/); const unknownFlag = run(['--bogus', '--', 'true']); @@ -78,7 +78,7 @@ if (enforcement === 'unusable') { console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock'); process.exit(0); } -const expectedNotice = enforcement === 'partial' ? `${PARTIAL_ENFORCEMENT_NOTICE}\n` : ''; +const expectedNotice = enforcement === 'partial' ? `${PARTIAL_NOTICE}\n` : ''; { const probeRun = run(['--probe']); assert.equal(probeRun.status, 0); @@ -129,7 +129,7 @@ const expectedNotice = enforcement === 'partial' ? `${PARTIAL_ENFORCEMENT_NOTICE const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`); const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]); assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT); - assert.ok(badGrant.stderr.startsWith(LAUNCHER_FATAL_PREFIX)); + assert.ok(badGrant.stderr.startsWith(FATAL_PREFIX)); assert.match(badGrant.stderr, /cannot open rule path/); assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails'); } diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index d3f124151e..1acca21825 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,18 +9,16 @@ 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, - LAUNCHER_FATAL_PREFIX, - PARTIAL_ENFORCEMENT_NOTICE, -} from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -const FATAL = `${LAUNCHER_FATAL_PREFIX}landlock ruleset error: Invalid argument` +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const FATAL_PREFIX = 'landlock-run: ' +const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument` const contexts: Context[] = [] const tempDirs: string[] = [] @@ -41,10 +39,10 @@ while [ "$#" -gt 0 ]; do case "$1" in --ro|--rw) shift 2 ;; --) shift; break ;; - *) printf '%s\\n' '${LAUNCHER_FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;; + *) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;; esac done -printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2 +printf '%s\\n' '${NOTICE}' >&2 ${fatalBranch}exec "$@" `, { mode: 0o755 }) return launcher @@ -113,7 +111,7 @@ describe('partial Landlock runner-failure classification', () => { const bash = await setup() const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) expect(result.exitCode).toBe(exitCode) - expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`) + expect(result.stderr.text).toBe(`${NOTICE}\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }, ) @@ -122,7 +120,7 @@ describe('partial Landlock runner-failure classification', () => { const bash = await setup() const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` })) expect(result.exitCode).toBe(exitCode) - expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`) + expect(result.stderr.text).toBe(`${NOTICE}\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) @@ -130,7 +128,7 @@ describe('partial Landlock runner-failure classification', () => { const bash = await setup(exitCode) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.exitCode).toBe(exitCode) - expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n${FATAL}\n`) + expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) @@ -140,13 +138,13 @@ describe('partial Landlock runner-failure classification', () => { expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toContain(`Runner failure: ${FATAL}`) - expect((error as Error).message).not.toContain(PARTIAL_ENFORCEMENT_NOTICE) + expect((error as Error).message).not.toContain(NOTICE) }) it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => { const bash = await setup() const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) - expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\nchild: Permission denied\n`) + expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`) expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }) @@ -156,7 +154,7 @@ describe('partial Landlock runner-failure classification', () => { const task = bash.start(bash.resolve({ command })) await task.done expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) - expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE) + expect(task.readOutput().delta).toContain(NOTICE) } }) @@ -165,7 +163,7 @@ describe('partial Landlock runner-failure classification', () => { const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) await task.done expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) - expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE) + expect(task.readOutput().delta).toContain(NOTICE) }) it('makes a background fatal line outrank denial text after the notice', async () => { @@ -179,7 +177,7 @@ describe('partial Landlock runner-failure classification', () => { runnerFailed: true, }) const output = task.readOutput().delta - expect(output).toContain(PARTIAL_ENFORCEMENT_NOTICE) + expect(output).toContain(NOTICE) expect(output).toContain(FATAL) }) }) diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 719bdca01f..bb0e5a56c1 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -8,9 +8,8 @@ import { spawnSync } from 'node:child_process' import { - LAUNCHER_FATAL_PREFIX, + LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, - PARTIAL_ENFORCEMENT_NOTICE, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, } from 'node-addon-landlock-run' @@ -157,8 +156,8 @@ const RUNNER_FAILURE_RULES = { bwrap: [{ fatalSignatures: ['bwrap: '] }], landlock: [{ allowedExitCodes: [LAUNCHER_FAILURE_EXIT], - fatalSignatures: [LAUNCHER_FATAL_PREFIX], - informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], + fatalSignatures: [`${LAUNCHER_BIN}: `], + informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], } as const satisfies Record diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 13f860a94a..74d4c2a8a1 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,11 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { - LAUNCHER_FAILURE_EXIT, - LAUNCHER_FATAL_PREFIX, - PARTIAL_ENFORCEMENT_NOTICE, -} from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { @@ -179,8 +175,8 @@ describe('the platform chains', () => { denialSignatures: ['permission denied'], runnerFailureRules: [{ allowedExitCodes: [LAUNCHER_FAILURE_EXIT], - fatalSignatures: [LAUNCHER_FATAL_PREFIX], - informationalLines: [PARTIAL_ENFORCEMENT_NOTICE], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], }], }) expect(probeLandlock).toHaveBeenCalledWith(launcher) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 252e5b3637..d98c864d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -848,10 +848,6 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:* version: link:../packages/context/workspace-context - node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 - packages/acp/acp: dependencies: '@agentclientprotocol/sdk': From b0f33f13f4ee5bfc40230fe6b1ae9a70ba4ffa23 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 13:49:34 +0800 Subject: [PATCH 061/108] docs(sandbox): cross-link Landlock snapshot rule --- .../acp-agent/tests/fixtures/partial-landlock-sandbox.ts | 5 ++++- packages/sandbox/sandbox-local/src/index.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 2ce25885f0..6d5080e9de 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -3,7 +3,10 @@ import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' -/** Snapshot-only provider that reproduces an older-ABI Landlock launch. */ +/** + * Snapshot-only provider that reproduces an older-ABI Landlock launch. Keep + * its failure tuple aligned with `sandbox-local`'s Landlock runner rule. + */ export default class PartialLandlockSandboxProvider extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { return { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index bb0e5a56c1..c4dd710b88 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -151,6 +151,8 @@ const DENIAL_SIGNATURES = { * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. + * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ const RUNNER_FAILURE_RULES = { bwrap: [{ fatalSignatures: ['bwrap: '] }], From 96ba98c99e034a407b79e428e5cd663053d3a0a5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 14:47:46 +0800 Subject: [PATCH 062/108] fix(sandbox): require runner-specific spawn evidence --- .../partial-landlock.cordis.snapshot.yml | 4 +- .../acp-agent/partial-landlock.cordis.yml | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 14 +++++ .../fixtures/partial-landlock-sandbox.ts | 17 ++++-- .../missing-sandbox-runner/input.json | 10 ++++ .../missing-sandbox-runner/session.jsonl | 47 ++++++++++++++++ .../stdout.expected.jsonl | 4 ++ packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 4 +- packages/bash/bash-sandbox/README.zh.md | 4 +- packages/bash/bash-sandbox/src/helpers.ts | 54 +++++++++++++++++-- packages/bash/bash-sandbox/src/index.ts | 31 ++++++++--- .../bash/bash-sandbox/tests/sandbox.spec.ts | 51 ++++++++++++++---- packages/bash/tool-bash/src/background.ts | 8 +-- 14 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml index 685f47d814..af834b885d 100644 --- a/examples/acp-agent/partial-landlock.cordis.snapshot.yml +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless partial-Landlock composition: replay the authored model turns and -# replace the shipping provider with a deterministic older-ABI stand-in. +# Keyless runner-classification composition: replay authored model turns and +# replace the shipping provider with a deterministic process-launch stand-in. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml index 41725299a8..7a958bb6de 100644 --- a/examples/acp-agent/partial-landlock.cordis.yml +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -1,5 +1,5 @@ -# Live counterpart for the partial-Landlock snapshot overlay. It replaces only -# the sandbox provider; the authored scenario is skipped in record mode. +# Live counterpart for the runner-classification snapshot overlay. It replaces +# only the sandbox provider; authored scenarios are skipped in record mode. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 94ab32e955..f6d57560a1 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -170,6 +170,20 @@ const SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'read-only' }, posixOnly: true, }, + // A valid cwd plus a missing provider executable exercises the assembled + // foreground error and background task marker without a platform runner. + { + name: 'missing-sandbox-runner', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { + DSH_PERMISSION_MODE: 'read-only', + DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER: '1', + }, + posixOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 6d5080e9de..6ac6b27a19 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -1,14 +1,25 @@ +import { join } from 'node:path' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const MISSING_RUNNER_ENV = 'DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER' /** - * Snapshot-only provider that reproduces an older-ABI Landlock launch. Keep - * its failure tuple aligned with `sandbox-local`'s Landlock runner rule. + * Snapshot-only provider for deterministic runner classification. Its default + * launch reproduces older-ABI Landlock; an explicit scenario flag selects a + * missing executable under the valid workspace cwd. */ export default class PartialLandlockSandboxProvider extends SandboxProvider { - confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + if (process.env[MISSING_RUNNER_ENV] === '1') { + return { + argv: [join(policy.workspaceRoot, '.dsh-missing-sandbox-runner'), ...argv], + enforcement: 'full', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ fatalSignatures: ['snapshot-runner: '] }], + } + } return { argv: [ 'bash', diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json new file mode 100644 index 0000000000..f75309e4e1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl new file mode 100644 index 0000000000..8866cc3da8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -0,0 +1,47 @@ +{"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":1785304900000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785825343526,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785825343527,"data":{"content":[{"type":"text","text":"Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop."}],"source":{"kind":"user"},"role":"user","id":"698c50b4-8e89-490a-910a-319466b322e8"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785825343527,"data":{"title":"Run true once with bash","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":1785825343547,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"0027efa5-7cd8-4c0c-b6d0-b483e99dd35a"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785825343547,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785825343548,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785825343548,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1785304900007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1785304900008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-foreground","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1785304900009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785304900010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":11,"time":1785304900011,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785825343557,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e30fc63-1414-487c-aab9-ad114818118c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785825343557,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} +{"type":"tool/result","seq":14,"time":1785825343574,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"48c382bd-1b30-4af0-b3e4-2c7bb811ba71"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785825343574,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785825343581,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1785304900017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-background","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":19,"time":1785304900019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":20,"time":1785304900020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":21,"time":1785825343587,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1785825343587,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0888df6a-237b-4b8b-9a4a-bdd8b42e1b3a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785825343587,"data":{"turn":1,"step":2,"callId":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":24,"time":1785825343595,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"missing-runner-background"},"content":[{"type":"tool-result","toolCallId":"missing-runner-background","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"59acf44c-7ad3-4778-a12f-997c76d657e3"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785825343595,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":26,"time":1785825343603,"data":{"content":[{"type":"text","text":"background task bash-1 (bash: true) finished [status: killed, killed before exit]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"4ca0c254-b6de-4c5a-8484-430ed6a69761"},"surfaceOp":"append"} +{"type":"step/start","seq":27,"time":1785825343603,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":29,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-output","name":"task_output","argumentsDelta":"{\"task_id\":\"bash-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":30,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":31,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":32,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":33,"time":1785825343608,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"19d8d568-a056-45e0-a3b4-21e26e7cbc26"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"tool/call","seq":34,"time":1785825343608,"data":{"turn":1,"step":3,"callId":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}} +{"type":"tool/result","seq":35,"time":1785825343615,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"missing-runner-output"},"content":[{"type":"tool-result","toolCallId":"missing-runner-output","content":[{"type":"text","text":"[stderr]\nspawn failed: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT\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]\n[status: killed, killed before exit]"}],"isError":false}],"role":"user","id":"3a637de6-c9f5-4474-9bbc-ee8ec354e27b"}},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1785825343615,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":37,"time":1785825343622,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":39,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"RUNNER_FAILURES_SURFACED"}}} +{"type":"assistant/chunk","seq":40,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"type":"assistant/chunk","seq":41,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":42,"time":1785825343628,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":43,"time":1785825343628,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59317a54-459a-4383-bcd3-eea76ab8de2a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"step/end","seq":44,"time":1785825343628,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":45,"time":1785825343628,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl new file mode 100644 index 0000000000..c7df2372dc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index df83d16576..f8d579055f 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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: c1a6d6ae19f6d5ec95087d32c7d5d18d5f28b7f5 -README.zh.md: a6bcc812e35c842c7d3880a41814a872f3dbca14 +README.md: d1f2554776d2e9b3a2a5fc1742c12ec69a5d4a97 +README.zh.md: 3aab06145c00e9df4bd06628129cf043769ffb49 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index c1a6d6ae19..d1f2554776 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -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 failures are sandbox failures, never command failures.** A rejected spawn of the provider argv is out-of-band proof that the confined launch never started: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. 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. +- **Runner failures are sandbox failures, never command failures.** Before a process starts, a spawn rejection is attributed to the runner only when its structured code and an independent lookup both show that the provider's executable is missing or unusable: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. Other spawn rejections retain the local executor's command-start failure semantics. 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. - **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/). @@ -72,7 +72,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 provider-argv spawn rejection supplies the original spawn error as detail; a settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. +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 provider-executable spawn failure supplies the original spawn error as detail; a rejection without runner-specific evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index a6bcc812e3..3aab06145c 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** spawn 提供方 argv 遭拒,是受限启动从未开始的带外证据:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当 spawn 拒绝的结构化错误码与一次独立查找均表明提供方可执行文件缺失或不可用时,才会将该拒绝归因于 runner:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。其他 spawn 拒绝保留本地执行器的命令启动失败语义。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 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/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。spawn 提供方 argv 遭拒时,以原始 spawn 错误作为详细信息;已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。提供方可执行文件的 spawn 失败会以原始 spawn 错误作为详细信息;没有 runner 特定证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index fc74dab8e3..a4eee9cbff 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -4,9 +4,56 @@ * @module @deepseek-ai/dsh-bash-sandbox/helpers */ +import { accessSync, constants, statSync } from 'node:fs' +import { delimiter, resolve } from 'node:path' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' +/** Spawn codes that can describe an unavailable executable. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM']) + +/** Whether one resolved path is a regular executable file. */ +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Require positive runner evidence instead of treating every spawn rejection + * as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated + * launch failures, so the provider executable must also be absent or unusable. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the spawn cwd, used to resolve relative executable paths. + * @param searchPath - the spawn environment's PATH value. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, + searchPath: string | undefined, +): boolean { + if (typeof error !== 'object' || error === null) return false + const code = (error as { code?: unknown }).code + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false + + const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\') + const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? [] + if (pathEntries.length === 0) return false + return pathEntries.every((entry) => { + const candidate = isPath + ? resolve(workdir, runnerProgram) + : resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram) + return !isExecutableFile(candidate) + }) +} + /** Fatal runner evidence retained for infrastructure-error detail. */ interface RunnerFailureMatch { /** The original stderr line that matched a fatal signature. */ @@ -43,11 +90,10 @@ export function classifyRunnerFailure( 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 substring matches every string in JavaScript. Ignore it so a - // malformed public rule cannot turn a gated exit status into evidence by - // itself; keep any valid signatures beside it active. + // 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.length > 0) + .filter(signature => signature.trim().length > 0) .map(signature => signature.toLowerCase()) for (const line of lines) { const lowered = line.toLowerCase() diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 8de2369a26..4fb9c1b1ed 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,10 +1,10 @@ /** * Sandbox-consuming bash executor. It wraps the exact local bash argv through * `ctx.sandbox`, inherits local process mechanics, and reports the selected - * mode, enforcement, and denial facts. Runner failure means the command never - * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background - * processes carry `runnerFailed`. The tool owns approval and passes a complete - * per-call policy. + * 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 owns approval and passes a complete per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ @@ -23,7 +23,7 @@ import type { import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' -import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — @@ -60,6 +60,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { enforcement: SandboxEnforcement denialSignatures: readonly string[] runnerFailureRules: readonly RunnerFailureRule[] + runnerProgram: string | undefined + searchPath: string | undefined + workdir: string }>() constructor(ctx: Context, config: Config) { @@ -97,7 +100,10 @@ export class SandboxBashExecutor extends LocalBashExecutor { } catch (error) { // An upstream abort remains cancellation even when it prevents spawn. if (spec.signal?.aborted === true) spec.signal.throwIfAborted() - throw new SandboxUnavailableError(mode, String(error)) + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir, spec.env?.PATH ?? process.env.PATH)) { + 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. @@ -116,7 +122,15 @@ export class SandboxBashExecutor extends LocalBashExecutor { const confined = this.confine(spec.command, { ...policy, mode }) const proc = this.startArgv(spec, confined.argv) const { enforcement, denialSignatures, runnerFailureRules } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules }) + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + searchPath: spec.env?.PATH ?? process.env.PATH, + workdir: spec.workdir, + }) return proc } @@ -131,7 +145,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { // A rejected spawn never started the confined launch. Otherwise runner // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed - || classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir, facts.searchPath) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6328d96db3..a113322ddd 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -17,7 +17,7 @@ import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' -import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) @@ -172,6 +172,22 @@ describe('fail closed', () => { controller.abort(reason) await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason) }) + + it.each(['read-only', 'danger-full-access'] as const)( + 'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode', + async (mode) => { + const { bash } = await setup({ mode }) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }, + ) }) describe('danger-full-access', () => { @@ -273,10 +289,26 @@ describe('classifyDenial', () => { }) }) +describe('isRunnerSpawnFailure', () => { + it('requires both an executable-class error and an independently unavailable provider executable', () => { + const missingRunner = join(spillDir, 'definitely-missing-runner') + const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) + const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' }) + + expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true) + expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false) + expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true) + expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false) + expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true) + expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false) + expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false) + }) +}) + describe('classifyRunnerFailure', () => { - it('ignores empty fatal signatures instead of treating exit status or notice text as evidence', () => { + it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => { const notice = 'landlock-run: partial enforcement (older Landlock ABI)' - const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: [''] }] + const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }] expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined() expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined() }) @@ -286,7 +318,7 @@ describe('classifyRunnerFailure', () => { const fatal = 'landlock-run: ruleset creation failed' const rules = [{ allowedExitCodes: [125], - fatalSignatures: ['', 'landlock-run: '], + fatalSignatures: ['', ' ', 'landlock-run: '], informationalLines: [notice], }] expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal }) @@ -354,10 +386,10 @@ describe('result facts', () => { }) describe('background sandbox facts', () => { - it('stamps facts and releases accounting when background spawn fails', async () => { + it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', async () => { const { bash } = await setup() - const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing') - const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) await task.done @@ -367,13 +399,13 @@ describe('background sandbox facts', () => { mode: 'read-only', denied: false, enforcement: 'full', - runnerFailed: true, }) const accounting = (bash as unknown as { processFacts: Map }).processFacts expect(accounting.size).toBe(0) + rmSync(parent, { recursive: true, force: true }) }) - it('classifies a spawn rejection whose reason is undefined', async () => { + it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -399,7 +431,6 @@ describe('background sandbox facts', () => { mode: 'read-only', denied: false, enforcement: 'full', - runnerFailed: true, }) }) diff --git a/packages/bash/tool-bash/src/background.ts b/packages/bash/tool-bash/src/background.ts index 6ba67cdfde..0269fe976c 100644 --- a/packages/bash/tool-bash/src/background.ts +++ b/packages/bash/tool-bash/src/background.ts @@ -16,10 +16,10 @@ 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 - // failure with a signal-less kill and a runner failure with an ordinary - // wrapper exit; real nonzero command exits must remain `completed`. + // infrastructure-failure outcome, then map it to task `failed`. Restricted + // runner failures expose sandbox.runnerFailed, but unconfined spawn failures + // still alias a signal-less kill; real nonzero command exits must remain + // `completed`. if (proc.status === 'killed') { return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } } From 1d2ea70e9b33f41a4399c768365f453df6e36209 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:53:02 +0800 Subject: [PATCH 063/108] feat(llm-deepseek): per-model output cap in the advisory catalog A catalog entry may now carry its own `maxTokens`, matching the shape the pi-ai adapter already exposes. Exact-model resolution prefers it over the profile value, so capping one model no longer means capping the route. Defaults are unchanged: an entry without a cap, and any unlisted pass-through id, still resolve to the profile `maxTokens` (256,000), and the shipped catalog keeps its context windows. --- docs/config-catalog.md | 4 +++- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 4 +++- packages/llm/llm-deepseek/src/index.ts | 10 +++++++++- .../llm/llm-deepseek/tests/adapter.spec.ts | 20 +++++++++++++++++++ 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f07a12f4a8..1613d39de6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -638,7 +638,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -660,6 +660,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } ``` diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..e75778f9f0 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -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/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: 72b04f5982ec7fdef024835ab23e7a9f0005f84c +README.zh.md: 33eb1986a90079a2b8d6768368287b121e2fbd6b diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..72b04f5982 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -40,7 +40,7 @@ The plugin registers the single provider route `deepseek-official` together with `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. -`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..33eb1986a9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -40,7 +40,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..c0d74d3f75 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -35,6 +35,8 @@ export interface DeepSeekCatalogModel { description?: string /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number + /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ + maxTokens?: number } /** @@ -181,7 +183,7 @@ export class DeepSeekAdapter extends LlmAdapter { ? { provider, id: model, name: model } : modelInfo(provider, configured), context: { contextWindow }, - defaultMaxTokens: connection.maxTokens, + defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, ...connection.defaults.thinking === 'disabled' ? { reasoning: { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3ecc0bec77..cd2bb9a24e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -68,7 +68,7 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Default per-request output cap (default 256,000); explicit request values win. */ + /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */ maxTokens?: number /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number @@ -85,6 +85,7 @@ const catalogModel: z = z.object({ name: z.string(), description: z.string(), contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), }) export const Config: z = z.object({ @@ -125,6 +126,12 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, ) } + if (model.maxTokens !== undefined + && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -132,6 +139,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..8ee2c81afe 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -793,6 +793,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + it('rejects invalid context capacity when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 17b480de51b1f4848eeeece172c3c7431b88423a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:53:17 +0800 Subject: [PATCH 064/108] feat(web): align the model catalog with the pi-ai provider form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both editors live in `ui-models` and list the same thing, so they now share one row shape rather than diverging when #1368 lands: a bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure. The context window is joined there by the per-model output cap the adapter just gained; both read a decimal K/M suffix. The shared class names carry this file's token spellings, not that branch's. `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails instead of shipping a light-only surface. The keystroke buffer is now per capacity field rather than per row, since a row holds two of them. --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 6 +- .../models.expected.md | 32 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/DeepSeekModelsEditor.tsx | 283 +++++++++++------- .../src/client/ModelsSection.module.css | 99 +++--- .../ui-models/src/client/ProviderEditor.tsx | 2 + .../client/ui-models/src/client/locales.ts | 8 + .../ui-models/tests/components.spec.tsx | 141 ++++++--- .../client/ui-models/tests/styles.spec.ts | 17 +- 14 files changed, 400 insertions(+), 204 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 32137fe409..79e779ffdf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: a4d474d450009b3bcf929eaea870e045602268ac -2026-07-30-web-config-plane.zh.md: db7201408f46a1e68b4359346d1c74b8d728a0d3 +2026-07-30-web-config-plane.md: 5970cfcea2e577998a235d08769ba497f5e8c18b +2026-07-30-web-config-plane.zh.md: 7a9fc6a7a0484ec7ae5d7691ffe26eddfd33fb54 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index a4d474d450..5970cfcea2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,7 +18,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. The context window is a text field reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: the field holds the typed text while the row has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. +**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. **The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index db7201408f..7a9fc6a7a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,7 +18,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。上下文窗口是一个文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:该行持有焦点期间,字段保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 **Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 984d19db05..51fa84af3d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -173,12 +173,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() - await settings.getByRole('button', { name: '删除模型' }).first().click() + await settings.getByRole('button', { name: /删除模型/ }).first().click() await settings.getByRole('button', { name: '添加模型' }).click() const customModelId = settings.getByLabel('模型 ID 2') await customModelId.fill('private-preview') await settings.getByLabel('显示名称 2').fill('Private Preview') + // Capacities live behind the row's own disclosure, as in the pi-ai form. + await settings.getByRole('button', { name: '容量 2' }).click() await settings.getByLabel('上下文窗口 2').fill('131072') + await settings.getByLabel('最大输出 token 数 2').fill('64K') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) @@ -190,6 +193,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('id: private-preview') expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') + expect(document).toContain('maxTokens: 64000') expect(document).not.toContain('id: deepseek-v4-flash') await page.keyboard.press('Escape') diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 4a709fdf5a..c71c1981ac 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -32,26 +32,34 @@ - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - - textbox "模型 ID 1": deepseek-v4-pro + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: deepseek-v4-pro - textbox "显示名称 1": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - textbox "上下文窗口 1": - - /placeholder: 1M - - text: 1M - - button "删除模型": + - button "容量 1": - img - - text: 删除模型 - - textbox "模型 ID 2": private-preview + - button "删除模型 1": + - img + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: private-preview - textbox "显示名称 2": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: Private Preview + - button "容量 2" [expanded]: + - img + - button "删除模型 2": + - img + - text: 上下文窗口 - textbox "上下文窗口 2": - /placeholder: 1M - text: "131072" - - button "删除模型": - - img - - text: 删除模型 + - text: 最大输出 token 数 + - textbox "最大输出 token 数 2": + - /placeholder: 256K + - text: 64K - button "添加模型": - img - text: 添加模型 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 65843fbadb..38b961df53 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -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/client/ui-models/README.md -README.md: 6ce66b5e0614fc2b6820c5d95f294fcc1a30d4de -README.zh.md: 0a28aa627d4d0c8dd54cffe690d334d4e10fa69d +README.md: d403b15247173e6290214eeae7a401851e835700 +README.zh.md: adb37bb25420bf4e038153ba8a5c213b8a993791 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ce66b5e06..d403b15247 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A context window is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional context windows fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 0a28aa627d..adb37bb254 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。上下文窗口按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx index 13aab18dab..57221cc93a 100644 --- a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -7,33 +7,46 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ export type DeepSeekModelDraft = Record -/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */ -const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i +/** The catalog fields this editor writes. */ +type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens' + +/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ +type CapacityField = 'contextWindow' | 'maxTokens' + +/** Row index encoded in an editing-buffer key. */ +function rowOf(key: string): number { + return Number(key.slice(0, key.indexOf(':'))) +} + +/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */ +const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i /** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */ -const CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const +const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const /** - * Read a typed context window, so a user can write `256K` or `1M` instead of - * counting zeroes. The stored value stays a plain token count. + * Read a typed capacity, so a user can write `256K` or `1M` instead of counting + * zeroes. The stored value stays a plain token count. * @param text - raw field text. * @returns the count; `undefined` when blank (inherit), `NaN` when unreadable * (rejected by {@link validateDeepSeekModels} before any write). */ -export function parseContextWindow(text: string): number | undefined { +export function parseCapacity(text: string): number | undefined { const trimmed = text.trim() if (trimmed.length === 0) return undefined - const match = CONTEXT_WINDOW_PATTERN.exec(trimmed) + const match = CAPACITY_PATTERN.exec(trimmed) if (match === null) return Number.NaN const suffix = match[2]?.toLowerCase() - const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1 + const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1 const scaled = Number(match[1]) * scale // A decimal multiple is exact in intent but not in binary floating point // (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back. @@ -43,15 +56,15 @@ export function parseContextWindow(text: string): number | undefined { /** * Spell a stored count back in the shortest form that survives a round trip - * through {@link parseContextWindow}; a count that is not a whole number of + * through {@link parseCapacity}; a count that is not a whole number of * thousands stays written out. - * @param value - stored context window. + * @param value - stored capacity. * @returns the field text. */ -export function formatContextWindow(value: number): string { +export function formatCapacity(value: number): string { if (!Number.isInteger(value) || value <= 0) return String(value) - if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M` - if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K` + if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M` + if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K` return String(value) } @@ -61,6 +74,7 @@ export interface DeepSeekModelsValidationFailure { index: number /** Message key owned by the Models settings section. */ key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid' + | 'modelMaxTokensInvalid' } /** Convert a schema-validated catalog value into records without dropping hidden fields. */ @@ -99,6 +113,11 @@ export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidation && (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) { return { index, key: 'modelContextInvalid' } } + const maxTokens = model['maxTokens'] + if (maxTokens !== undefined + && (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) { + return { index, key: 'modelMaxTokensInvalid' } + } } return undefined } @@ -109,8 +128,10 @@ export interface DeepSeekModelsEditorProps { models: readonly DeepSeekModelDraft[] /** Whether the user layer currently owns the whole array. */ overridden: boolean - /** Fallback capacity used when a row omits its exact value. */ + /** Fallback context capacity used when a row omits its exact value. */ defaultContextWindow: number | undefined + /** Fallback output cap used when a row omits its exact value. */ + defaultMaxTokens: number | undefined /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every mutation. */ @@ -122,25 +143,27 @@ export interface DeepSeekModelsEditorProps { } /** - * Render the direct DeepSeek adapter's id/name/context-window catalog. + * Render the direct DeepSeek adapter's model catalog: id and display name on + * each row, capacities behind the row's own disclosure. * @param props - effective rows plus the array-level override actions. * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { - // Context windows are edited as text, so a row's keystrokes are held here + // Capacities are edited as text, so a field's keystrokes are held here // rather than re-derived from the parsed count on every change, which would // rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the // save-time rejection names a row the user can still see — which is why - // this is one entry PER ROW: a single active buffer would be displaced by - // editing any other row, and the abandoned row would fall back to rendering - // its stored NaN as the literal `NaN`. + // this is one entry PER FIELD: a single active buffer would be displaced by + // editing any other field, and the abandoned one would fall back to + // rendering its stored NaN as the literal `NaN`. // - // Entries are keyed by row index, so the two operations that move indexes - // maintain them: `remove` re-keys around the dropped row, and reset clears - // them all because the rows they annotated are gone. - const [editing, setEditing] = useState>(() => new Map()) + // Keys carry the row index, so the two operations that move indexes maintain + // them: `remove` re-keys around the dropped row, and reset clears them all + // because the rows they annotated are gone. + const [editing, setEditing] = useState>(() => new Map()) + const [expanded, setExpanded] = useState>(() => new Set()) - const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { + const update = (index: number, key: CatalogField, value: unknown): void => { const next = props.models.map((model, at) => { const copy = { ...model } if (at !== index) return copy @@ -153,10 +176,20 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const remove = (index: number): void => { setEditing((current) => { - const next = new Map() - for (const [at, text] of current) { + const next = new Map() + for (const [key, text] of current) { + const at = rowOf(key) if (at === index) continue - next.set(at > index ? at - 1 : at, text) + // Only the row number moves; the field half of the key is untouched. + next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text) + } + return next + }) + setExpanded((current) => { + const next = new Set() + for (const at of current) { + if (at === index) continue + next.add(at > index ? at - 1 : at) } return next }) @@ -165,34 +198,73 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const reset = (): void => { setEditing(new Map()) + setExpanded(new Set()) props.onReset() } - /** The row's field text: its live keystrokes, else the stored count spelled short. */ - const contextText = (model: DeepSeekModelDraft, index: number): string => { - const typed = editing.get(index) - if (typed !== undefined) return typed - const value = model['contextWindow'] - return typeof value === 'number' ? formatContextWindow(value) : '' - } - - const settleContext = (index: number): void => { - const typed = editing.get(index) - if (typed === undefined) return - // Unreadable text stays on screen: the save-time rejection names a row the - // user can still see and correct. - const parsed = parseContextWindow(typed) - if (parsed !== undefined && Number.isNaN(parsed)) return - setEditing((current) => { - const next = new Map(current) - next.delete(index) + const toggle = (index: number): void => { + setExpanded((current) => { + const next = new Set(current) + if (!next.delete(index)) next.add(index) return next }) } + /** The field's text: its live keystrokes, else the stored count spelled short. */ + const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => { + const typed = editing.get(`${String(index)}:${field}`) + if (typed !== undefined) return typed + const value = model[field] + return typeof value === 'number' ? formatCapacity(value) : '' + } + + const settleCapacity = (index: number, field: CapacityField): void => { + const key = `${String(index)}:${field}` + const typed = editing.get(key) + if (typed === undefined) return + // Unreadable text stays on screen: the save-time rejection names a row the + // user can still see and correct. + const parsed = parseCapacity(typed) + if (parsed !== undefined && Number.isNaN(parsed)) return + setEditing((current) => { + const next = new Map(current) + next.delete(key) + return next + }) + } + + /** One capacity field of one row, rendered inside the row's disclosure. */ + const capacityField = ( + model: DeepSeekModelDraft, + index: number, + field: CapacityField, + fallback: number | undefined, + ): ReactNode => ( + + ) + return (
-
+
{props.t('models')} @@ -215,66 +287,65 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod {props.models.length === 0 ?

{props.t('modelsEmpty')}

: ( -
- {/* Captions sit above the rows and are hidden from assistive tech: - every field already carries the indexed `aria-label` naming it. */} - +
{props.models.map((model, index) => ( -
- { update(index, 'id', event.target.value) }} - onBlur={(event) => { - // Settle a pasted id rather than trimming per keystroke, - // which would stop the user typing an interior space. - const trimmed = event.target.value.trim() - if (trimmed !== event.target.value) update(index, 'id', trimmed) - }} - /> - { - update(index, 'name', event.target.value === '' ? undefined : event.target.value) - }} - /> - { - const text = event.target.value - setEditing(current => new Map(current).set(index, text)) - update(index, 'contextWindow', parseContextWindow(text)) - }} - onBlur={() => { settleContext(index) }} - /> - +
+
+ { update(index, 'id', event.target.value) }} + onBlur={(event) => { + // Settle a pasted id rather than trimming per keystroke, + // which would stop the user typing an interior space. + const trimmed = event.target.value.trim() + if (trimmed !== event.target.value) update(index, 'id', trimmed) + }} + /> + { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + + +
+ {expanded.has(index) + ? ( +
+ {capacityField(model, index, 'contextWindow', props.defaultContextWindow)} + {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)} +
+ ) + : null}
))}
diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index eb000d4d99..2e7ea02326 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -156,8 +156,7 @@ .dangerButton:disabled, .addButton:disabled, .linkButton:disabled, -.addModelButton:disabled, -.rowDelete:disabled { +.addModelButton:disabled { opacity: 0.4; cursor: default; } @@ -168,7 +167,7 @@ .addButton:focus-visible, .linkButton:focus-visible, .addModelButton:focus-visible, -.rowDelete:focus-visible, +.iconButton:focus-visible, .customizedSummary:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); @@ -347,13 +346,6 @@ border-top: 1px solid var(--dsw-alias-border-l2); } -.modelCatalogHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - .modelCatalogHeading { display: flex; flex-direction: column; @@ -375,54 +367,89 @@ line-height: 18px; } -.modelTable { +/* Model list, shared with the pi-ai provider form (PR #1368): one bordered + entry per model, id and display name on the row, capacities behind the + row's own disclosure. The token names are this file's, not that branch's — + `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and + `--dsw-alias-text-primary` are undefined here and resolve to their + light-mode literals, which is the defect this section was just moved off. */ +.modelList { display: flex; flex-direction: column; - gap: 6px; -} - -/* Captions and rows share one track list so the columns line up. */ -.modelColumns, -.modelRow { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px; - align-items: center; gap: 8px; } -.modelColumns { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; +.modelListHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; } -/* The inset belongs on the caption cell, not the strip: padding on the grid - container would narrow its tracks against the rows' and walk the captions - left column by column. 1px border + 10px padding is the field text inset. */ -.modelColumns > span { - padding-left: 11px; +.modelEntry { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 6px; } -.rowDelete { - box-sizing: border-box; - position: relative; +.modelRow { display: grid; - place-items: center; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; +} + +/* Square, label-free affordances: the row's own inputs carry the meaning, so + the actions stay glyphs and announce themselves through aria-label. */ +.iconButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; width: 28px; height: 28px; - padding: 0; border: none; - border-radius: 8px; + border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; } -.rowDelete:hover:not(:disabled) { +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.iconButton:disabled { + cursor: default; + opacity: 0.4; +} + +/* The delete glyph keeps the danger tint the rest of the section uses. */ +.iconButtonDanger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); } +.modelAdvanced { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + padding: 8px 4px 2px; +} + +.modelField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modelFieldLabel { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + .modelEmpty { padding: 12px; border: 1px dashed var(--dsw-alias-border-l3); diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index a27fa14002..6876e58356 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -262,6 +262,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = getPath(fallback, ['maxTokens']) return ( <>
@@ -323,6 +324,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultContextWindow={typeof defaultContextWindow === 'number' ? defaultContextWindow : undefined} + defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} t={t} disabled={disabled} onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index f64700b11f..bb1254e46b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -40,6 +40,9 @@ export const en = { modelNamePlaceholder: 'Uses the model ID when empty', contextWindow: 'Context window', contextWindowPlaceholder: 'Uses the provider default', + maxTokens: 'Max output tokens', + maxTokensPlaceholder: 'Uses the provider default', + modelAdvanced: 'Capacities', addModel: 'Add model', removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', @@ -47,6 +50,7 @@ export const en = { modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.', + modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.', advancedHint: 'Other fields live in settings.yaml; edit that section directly.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -97,6 +101,9 @@ export const zh: typeof en = { modelNamePlaceholder: '留空时使用模型 ID', contextWindow: '上下文窗口', contextWindowPlaceholder: '使用提供方默认值', + maxTokens: '最大输出 token 数', + maxTokensPlaceholder: '使用提供方默认值', + modelAdvanced: '容量', addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', @@ -104,6 +111,7 @@ export const zh: typeof en = { modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。', + modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7346332a2f..6c053bba59 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -9,7 +9,7 @@ import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { - DeepSeekModelsEditor, formatContextWindow, modelDrafts, parseContextWindow, validateDeepSeekModels, + DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' @@ -19,6 +19,16 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] +/** Open one row's capacity disclosure (1-based, as the labels read). */ +function expandRow(position: number): void { + fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`)) +} + +/** The capacity inputs of every open row, in row order. */ +function capacityInputs(label: string): HTMLInputElement[] { + return screen.getAllByLabelText(new RegExp(label)) +} + const PiAiConfig = Schema.object({ token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ @@ -79,9 +89,10 @@ function wireNamespaces(): SettingsNamespaceView[] { baseURL: 'https://base', reasoningEffort: 'high', defaultContextWindow: 1_000_000, + maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS, }, - base: { defaultContextWindow: 1_000_000, models: DEFAULT_DEEPSEEK_MODELS }, + base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { reasoningEffort: 'high' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], @@ -297,10 +308,11 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) const names = screen.getAllByLabelText(new RegExp(en.modelName)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(3) fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) - fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } }) + // Only row 3 is open, so its capacity is addressed by its own label. + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -345,39 +357,46 @@ describe('ModelsSection', () => { expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }])) .toEqual({ index: 0, key: 'modelContextInvalid' }) expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined() + expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }])) + .toEqual({ index: 0, key: 'modelMaxTokensInvalid' }) + expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined() }) it('reads context windows written as counts, thousands, or millions', () => { - expect(parseContextWindow('')).toBeUndefined() - expect(parseContextWindow(' ')).toBeUndefined() - expect(parseContextWindow('131072')).toBe(131_072) - expect(parseContextWindow(' 256K ')).toBe(256_000) - expect(parseContextWindow('256k')).toBe(256_000) - expect(parseContextWindow('1M')).toBe(1_000_000) - expect(parseContextWindow('1m')).toBe(1_000_000) + expect(parseCapacity('')).toBeUndefined() + expect(parseCapacity(' ')).toBeUndefined() + expect(parseCapacity('131072')).toBe(131_072) + expect(parseCapacity(' 256K ')).toBe(256_000) + expect(parseCapacity('256k')).toBe(256_000) + expect(parseCapacity('1M')).toBe(1_000_000) + expect(parseCapacity('1m')).toBe(1_000_000) // 1M is 1000K, not 1024K: capacities are quoted in decimal. - expect(parseContextWindow('1M')).toBe(parseContextWindow('1000K')) + expect(parseCapacity('1M')).toBe(parseCapacity('1000K')) // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral // intent must not become a fractional count the validator rejects. - expect(parseContextWindow('2.3M')).toBe(2_300_000) - expect(Number.isInteger(parseContextWindow('1.5M'))).toBe(true) + expect(parseCapacity('2.3M')).toBe(2_300_000) + expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true) // A genuinely fractional count survives as one, for the validator to reject. - expect(parseContextWindow('0.0001K')).toBeCloseTo(0.1) - expect(parseContextWindow('abc')).toBeNaN() - expect(parseContextWindow('1G')).toBeNaN() - expect(parseContextWindow('1M1')).toBeNaN() + expect(parseCapacity('0.0001K')).toBeCloseTo(0.1) + expect(parseCapacity('abc')).toBeNaN() + expect(parseCapacity('1G')).toBeNaN() + expect(parseCapacity('1M1')).toBeNaN() }) it('spells a stored count in the shortest form that round-trips', () => { - expect(formatContextWindow(1_000_000)).toBe('1M') - expect(formatContextWindow(256_000)).toBe('256K') - expect(formatContextWindow(1_500_000)).toBe('1500K') - expect(formatContextWindow(131_072)).toBe('131072') + expect(formatCapacity(1_000_000)).toBe('1M') + expect(formatCapacity(256_000)).toBe('256K') + expect(formatCapacity(1_500_000)).toBe('1500K') + expect(formatCapacity(131_072)).toBe('131072') // Values the validator will reject are shown as-is rather than dressed up. - expect(formatContextWindow(Number.NaN)).toBe('NaN') - expect(formatContextWindow(0)).toBe('0') + expect(formatCapacity(Number.NaN)).toBe('NaN') + expect(formatCapacity(0)).toBe('0') for (const text of ['1M', '256K', '131072', '1500K']) { - expect(formatContextWindow(parseContextWindow(text) as number)).toBe(text) + expect(formatCapacity(parseCapacity(text) as number)).toBe(text) } }) @@ -386,7 +405,9 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) // The inherited 1000000 reads back short. expect((windows[0] as HTMLInputElement).value).toBe('1M') @@ -422,7 +443,9 @@ describe('ModelsSection', () => { it('keeps unreadable context-window text on screen and refuses the write', async () => { const { mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } }) // Blurring a row that is not the edited one leaves the buffer alone. fireEvent.blur(windows[1] as HTMLInputElement) @@ -483,7 +506,9 @@ describe('ModelsSection', () => { // losing the text the user was told they could still correct. await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } }) fireEvent.blur(windows[0] as HTMLInputElement) fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } }) @@ -495,13 +520,15 @@ describe('ModelsSection', () => { it('re-keys the typed text around a removed row', async () => { await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = (): HTMLInputElement[] => - screen.getAllByLabelText(new RegExp(en.contextWindow)) + const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { - fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement) + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement) } // Three rows, with text parked on the outer two. fireEvent.click(screen.getByText(en.addModel)) + expandRow(1) + expandRow(2) + expandRow(3) fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } }) fireEvent.blur(windows()[0] as HTMLInputElement) fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } }) @@ -529,12 +556,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } }) fireEvent.blur(windows[0] as HTMLInputElement) fireEvent.click(screen.getByText(en.resetModels)) - const restored = screen.getAllByLabelText(new RegExp(en.contextWindow)) + // Reset collapses every row, so the restored capacity needs opening again. + expandRow(1) + const restored = capacityInputs(en.contextWindow) expect((restored[0] as HTMLInputElement).value).toBe('1M') // Reset put the draft back where it started, so Apply writes nothing at @@ -544,6 +574,40 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it('edits an output cap per model and carries its text across a removal', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + // The profile's own cap is the placeholder both rows inherit. + expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K']) + + fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } }) + fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`)) + expect(screen.getByLabelText(`${en.maxTokens} 2`).value).toBe('64K') + + // Dropping the row above carries the cap text down with its own row. + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + expect(screen.getByLabelText(`${en.maxTokens} 1`).value).toBe('64K') + // The disclosure closes on a second press. + expandRow(1) + expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull() + + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-deepseek', + ops: [{ + op: 'set', + path: ['models'], + value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }], + }], + expectedRevision: 0, + }) + }) + it('settles a pasted id and refuses whitespace that would never match', async () => { await mountSection() fireEvent.click(screen.getByText(en.customized)) @@ -567,14 +631,18 @@ describe('ModelsSection', () => { models={[{}]} overridden={false} defaultContextWindow={undefined} + defaultMaxTokens={undefined} t={t} disabled={true} onChange={vi.fn()} onReset={vi.fn()} />) expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('') + expandRow(1) expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder) .toBe(en.contextWindowPlaceholder) + expect(screen.getByLabelText(`${en.maxTokens} 1`).placeholder) + .toBe(en.maxTokensPlaceholder) }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { @@ -582,14 +650,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement) - fireEvent.click(screen.getByText(en.removeModel)) + fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) + fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel))) expect(screen.getByText(en.modelsEmpty)).toBeTruthy() fireEvent.click(screen.getByText(en.resetModels)) expect(screen.getByText(en.modelsInherited)).toBeTruthy() const names = screen.getAllByLabelText(new RegExp(en.modelName)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + const windows = capacityInputs(en.contextWindow) fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } }) fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } }) fireEvent.click(screen.getByText(en.apply)) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 9139d01f4e..879d9812a2 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -3,6 +3,10 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') +const tokens = readFileSync( + fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)), + 'utf8', +) /** The declarations of one top-level rule, by selector. */ function block(selector: string): string { @@ -12,12 +16,15 @@ function block(selector: string): string { } describe('ModelsSection theme styles', () => { - it('uses the shared theme tokens without light-only fallbacks', () => { - // The section once named `--border`/`--surface`/`--text-*`/`--accent-strong`, - // which nothing in this app defines, so it rendered the light-mode literals - // written as their fallbacks and stayed light under the dark theme. + it('names only theme variables the token sheet defines', () => { + // A `--dsw-*` name the sheet never declares is not a near miss: it silently + // resolves to whatever literal sits in its fallback slot, which is how this + // section stayed light under the dark theme before. Undeclared names have + // no fallback at all and inherit, so both spellings must fail here. + const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1]) + const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`)) + expect(undeclared).toEqual([]) expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) - expect(css).toContain('color: var(--dsw-alias-label-primary)') }) it('separates the row card from the editor it expands into', () => { From 293fac94214c52c9c7aefeb3f592fcf3de7bc0ed Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 16:06:54 +0800 Subject: [PATCH 065/108] fix(sandbox): round 5 harden spawn attribution --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 12 +- .../feature/2026-07-06-sandbox.zh.md | 12 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 4 +- docs/core-data-structures/sandbox.zh.md | 4 +- .../fixtures/partial-landlock-sandbox.ts | 3 +- packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 4 +- packages/bash/bash-sandbox/README.zh.md | 4 +- packages/bash/bash-sandbox/src/helpers.ts | 36 ++-- packages/bash/bash-sandbox/src/index.ts | 19 +- .../tests/partial-landlock.spec.ts | 5 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 164 ++++++++++++++---- 14 files changed, 192 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index a30b48d5ae..0ffcc0cee3 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: c3f78c4bd05063c7c34621e6a3ec007d394b6d4c -2026-07-06-sandbox.zh.md: 984a8d71854344d41280e956b3c985659a9b066c +2026-07-06-sandbox.md: b037be29e00e997e3689199d57e78940f6657e60 +2026-07-06-sandbox.zh.md: 5863d3c62781031c57611589024cb8469961de95 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index c3f78c4bd0..b037be29e0 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -60,7 +60,7 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so missing or unexecutable runners fail through the spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured Bash script necessarily owns its interpreter startup before it applies its profile. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so missing, non-executable, or unloadable runners fail through the spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured script necessarily owns its interpreter startup before it applies its profile. The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. @@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged; a provider-argv spawn rejection becomes `SANDBOX_UNAVAILABLE` with its original detail in foreground and stamps `runnerFailed: true`, `denied: false` in background. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection is runner-owned only when the caller-owned workdir is independently usable and the error carries Node spawn provenance for provider argv[0] plus an executable-class code; invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution and a synchronous background start convert runner-owned rejections to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls and unloadable-runner evidence, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. -- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. +- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. ## Deferred phases @@ -172,7 +172,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. -- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces as a direct spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. +- **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. @@ -185,7 +185,7 @@ Costs and accepted limits: ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** A provider-argv spawn rejection proves the confined launch never started. After it starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground throws structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; a background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. +- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports an executable-class spawn failure for that argv[0]. Other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground and synchronous background-start failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 984a8d7185..5863d3c627 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -60,7 +60,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此 runner 缺失或不可执行时会通过 spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的 Bash 脚本必然要先启动解释器,再应用自身 profile。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此 runner 缺失、不可执行或无法加载时会通过 spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器,再应用自身 profile。 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 @@ -70,7 +70,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播;spawn 提供方 argv 遭拒时,前台会将其转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息,后台则盖章 `runnerFailed: true`、`denied: false`。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且错误带有 Node 为提供方 argv[0] 报告的 spawn 来源信息和可执行文件类错误码时,拒绝才会归因于 runner;无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行和同步启动后台任务时会将可归因于 runner 的拒绝转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 @@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、spawn 层失败、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 +- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照及 runner 无法加载证据的 spawn 层失败、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 -- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 +- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 ## 延迟阶段 @@ -172,7 +172,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 - **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 -- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会表现为直接 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 +- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 - **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 @@ -185,7 +185,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** spawn 提供方 argv 遭拒证明受限启动从未开始。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 +- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可执行文件类 spawn 失败时,才能据此判定 runner 损坏。其他拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败与同步的后台启动失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 8b5817065e..1a92643f24 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -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 docs/core-data-structures/sandbox.md -sandbox.md: 63a80084c5b6b25d55495d2d96daef73380993f6 -sandbox.zh.md: 447552e5c80b9cf3f4dd0b684c0607843a064448 +sandbox.md: fd4861f3d89aecfd9bee8bbfcf7ac1abe15ac937 +sandbox.zh.md: 2aac7f6dfc5feeb9bf3192487a18eaaeef832e10 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 63a80084c5..fd4861f3d8 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing or unexecutable runner rejects through the spawn channel rather than a stderr rule; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches. +The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing, non-executable, or unloadable runner — including a script whose shebang interpreter is unavailable — rejects through the spawn channel rather than a stderr rule; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A direct spawn rejection of the returned argv proves the confined launch never started and carries the same infrastructure meaning with the original error as detail. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Any direct spawn rejection of the returned argv proves the confined launch never started, but only an executable-class error with Node spawn provenance for provider argv[0] after the caller-owned workdir is independently verified usable carries infrastructure meaning and the original error as detail. Invalid or unusable workdirs, resource failures, unrelated syscalls, and unstructured rejections retain the consumer's ordinary command-start semantics. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy. Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 447552e5c8..2aac7f6dfc 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此 runner 缺失或不可执行时会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名。 +面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此 runner 缺失、不可执行或无法加载(包括脚本的 shebang 解释器不可用)时会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时若遭拒,即可证明受限启动从未开始;该拒绝具有相同的基础设施含义,并以原始错误作为详细信息。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时,任何拒绝都能证明受限启动从未开始;但只有在调用方拥有的 workdir 经独立验证可用,且错误属于带有 Node 为提供方 argv[0] 报告的 spawn 来源信息的可执行文件类错误时,该拒绝才具有基础设施含义,并以原始错误作为详细信息。无效或不可用的 workdir、资源失败、无关 syscall 和无结构拒绝仍保留消费方的普通命令启动语义。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。 提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 6ac6b27a19..dd0a2624f1 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -8,7 +8,8 @@ const MISSING_RUNNER_ENV = 'DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER' /** * Snapshot-only provider for deterministic runner classification. Its default * launch reproduces older-ABI Landlock; an explicit scenario flag selects a - * missing executable under the valid workspace cwd. + * missing executable under the valid workspace cwd. Keep the Landlock tuple + * aligned with `RUNNER_FAILURE_RULES` in `packages/sandbox/sandbox-local/src/index.ts`. */ export default class PartialLandlockSandboxProvider extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index f8d579055f..bfd55d33a5 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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: d1f2554776d2e9b3a2a5fc1742c12ec69a5d4a97 -README.zh.md: 3aab06145c00e9df4bd06628129cf043769ffb49 +README.md: 0fe8feaa0efda4111e997292a9741fffb6b55635 +README.zh.md: 43f6a209473839df7cbcfbefe4d11e29ff3de4b0 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index d1f2554776..0fe8feaa0e 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -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 failures are sandbox failures, never command failures.** Before a process starts, a spawn rejection is attributed to the runner only when its structured code and an independent lookup both show that the provider's executable is missing or unusable: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. Other spawn rejections retain the local executor's command-start failure semantics. 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. +- **Runner failures are sandbox failures, never command failures.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and the error carries Node spawn provenance for provider argv[0] plus an executable-class code. This covers missing, non-executable, and unloadable runners, including a script whose shebang interpreter is unavailable; an invalid or unusable workdir, resource failure, unrelated syscall, or unstructured rejection retains the local executor's command-start failure semantics. Foreground execution and a synchronous background start throw `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. 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. - **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/). @@ -72,7 +72,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 provider-executable spawn failure supplies the original spawn error as detail; a rejection without runner-specific evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. +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 executable-class 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. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index 3aab06145c..43f6a20947 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当 spawn 拒绝的结构化错误码与一次独立查找均表明提供方可执行文件缺失或不可用时,才会将该拒绝归因于 runner:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。其他 spawn 拒绝保留本地执行器的命令启动失败语义。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且错误带有 Node 为提供方 argv[0] 报告的 spawn 来源信息和可执行文件类错误码时,才会将拒绝归因于 runner。这样可以识别缺失、不可执行或无法加载的 runner,包括 shebang 解释器不可用的脚本;无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行与同步的后台启动会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 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/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。提供方可执行文件的 spawn 失败会以原始 spawn 错误作为详细信息;没有 runner 特定证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 argv[0] 可执行文件类证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index a4eee9cbff..1c3ea82eb2 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -5,17 +5,16 @@ */ import { accessSync, constants, statSync } from 'node:fs' -import { delimiter, resolve } from 'node:path' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' /** Spawn codes that can describe an unavailable executable. */ const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM']) -/** Whether one resolved path is a regular executable file. */ -function isExecutableFile(path: string): boolean { +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { try { - if (!statSync(path).isFile()) return false + if (!statSync(path).isDirectory()) return false accessSync(path, constants.X_OK) return true } catch { @@ -24,34 +23,27 @@ function isExecutableFile(path: string): boolean { } /** - * Require positive runner evidence instead of treating every spawn rejection - * as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated - * launch failures, so the provider executable must also be absent or unusable. + * Attribute only executable-class failures with Node spawn provenance after + * independently ruling out the caller-owned cwd. With a usable cwd, these + * codes describe resolution, permissions, or loading of the provider's + * argv[0], including a script whose shebang interpreter is unavailable. * @param error - the original spawn rejection. * @param runnerProgram - provider argv[0], the executable that establishes confinement. - * @param workdir - the spawn cwd, used to resolve relative executable paths. - * @param searchPath - the spawn environment's PATH value. + * @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, - searchPath: string | undefined, ): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false if (typeof error !== 'object' || error === null) return false - const code = (error as { code?: unknown }).code - if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false - - const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\') - const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? [] - if (pathEntries.length === 0) return false - return pathEntries.every((entry) => { - const candidate = isPath - ? resolve(workdir, runnerProgram) - : resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram) - return !isExecutableFile(candidate) - }) + 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' || (syscall !== 'spawn' && !syscall.startsWith('spawn '))) return false + if (path !== undefined && (typeof path !== 'string' || (path.length > 0 && path !== runnerProgram))) return false + return true } /** Fatal runner evidence retained for infrastructure-error detail. */ diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 4fb9c1b1ed..6e0a323675 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -61,7 +61,6 @@ export class SandboxBashExecutor extends LocalBashExecutor { denialSignatures: readonly string[] runnerFailureRules: readonly RunnerFailureRule[] runnerProgram: string | undefined - searchPath: string | undefined workdir: string }>() @@ -100,7 +99,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { } 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, spec.env?.PATH ?? process.env.PATH)) { + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { throw new SandboxUnavailableError(mode, String(error)) } throw error @@ -118,9 +117,18 @@ export class SandboxBashExecutor extends LocalBashExecutor { const policy = spec.sandboxPolicy as SandboxExecutionPolicy const { mode } = policy if (mode === 'danger-full-access') return super.start(spec) - // Install facts synchronously; promise settlement cannot run before start() returns. + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. const confined = this.confine(spec.command, { ...policy, mode }) - const proc = this.startArgv(spec, confined.argv) + 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, @@ -128,7 +136,6 @@ export class SandboxBashExecutor extends LocalBashExecutor { denialSignatures, runnerFailureRules, runnerProgram: confined.argv[0], - searchPath: spec.env?.PATH ?? process.env.PATH, workdir: spec.workdir, }) return proc @@ -145,7 +152,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { // 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, facts.searchPath) + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 1acca21825..36b2fb1898 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -79,11 +79,14 @@ async function setupConfiguredRunner(runner: string): Promise { - it.each(['missing', 'unexecutable'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => { + it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-')) tempDirs.push(dir) const runner = join(dir, `${kind}-runner`) if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 }) + if (kind === 'missing-interpreter') { + await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 }) + } const bash = await setupConfiguredRunner(runner) const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index a113322ddd..fa3c70caa9 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -34,6 +34,13 @@ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const /** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const +/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */ +const RUNNER_FORMS = [ + ['absolute', process.execPath], + ['bare', 'node'], + ['relative', './sandbox-runner'], +] as const + /** 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: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }) @@ -173,10 +180,15 @@ describe('fail closed', () => { await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason) }) - it.each(['read-only', 'danger-full-access'] as const)( - 'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode', - async (mode) => { - const { bash } = await setup({ mode }) + it.each(RUNNER_FORMS)( + 'keeps an invalid workdir ordinary with the %s provider-runner form', + async (_form, runner) => { + const { bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) try { const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) @@ -188,6 +200,63 @@ describe('fail closed', () => { } }, ) + + it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => { + const { bash } = await setup({ mode: 'danger-full-access' }) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('classifies synchronous ENOEXEC as runner loading failure in run() and start()', async () => { + const runner = join(spillDir, 'malformed-runner') + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { + throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn', path: runner }) + }) + + await expect(bash.run(bash.resolve({ command: 'true' }))) + .rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(() => bash.start(bash.resolve({ command: 'true' }))) + .toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })) + }) + + it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => { + const runner = './sandbox-runner' + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + const workdir = join(parent, 'missing') + const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner }) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure }) + try { + let thrown: unknown + try { + bash.start(bash.resolve({ command: 'true', workdir })) + } catch (error) { + thrown = error + } + expect(thrown).toBe(failure) + expect(thrown).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) }) describe('danger-full-access', () => { @@ -290,18 +359,44 @@ describe('classifyDenial', () => { }) describe('isRunnerSpawnFailure', () => { - it('requires both an executable-class error and an independently unavailable provider executable', () => { - const missingRunner = join(spillDir, 'definitely-missing-runner') - const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) - const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' }) + it.each(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])( + 'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated', + (code) => { + const runner = join(spillDir, 'runner') + const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true) + }, + ) - expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true) - expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false) - expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true) - expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false) - expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true) - expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false) - expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false) + it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => { + const missingWorkdir = join(spillDir, 'missing-workdir') + for (const [, runner] of RUNNER_FORMS) { + const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false) + } + const fileWorkdir = join(spillDir, 'not-a-workdir') + writeFileSync(fileWorkdir, '') + const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' }) + expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false) + }) + + it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => { + const missingRunner = join(spillDir, 'definitely-missing-runner') + const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) => + Object.assign(new Error('spawn failed'), { code, syscall, path }) + + expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', undefined), missingRunner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false) }) }) @@ -386,23 +481,30 @@ describe('result facts', () => { }) describe('background sandbox facts', () => { - it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', async () => { - const { bash } = await setup() - const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) - const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) - - await task.done - - expect(task.status).toBe('killed') - expect(task.readOutput().delta).toContain('spawn failed:') - expect(task.sandbox).toEqual({ - mode: 'read-only', - denied: false, + it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => { + const { bash } = await setup({}, argv => ({ + argv: [runner, ...argv], enforcement: 'full', - }) - const accounting = (bash as unknown as { processFacts: Map }).processFacts - expect(accounting.size).toBe(0) - rmSync(parent, { recursive: true, force: true }) + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + await task.done + + expect(task.status).toBe('killed') + expect(task.readOutput().delta).toContain('spawn failed:') + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + }) + const accounting = (bash as unknown as { processFacts: Map }).processFacts + expect(accounting.size).toBe(0) + } finally { + rmSync(parent, { recursive: true, force: true }) + } }) it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { From fe818862e1649ab45fbf0a8de9a879b1db21f9ac Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 16:20:04 +0800 Subject: [PATCH 066/108] fix(sandbox): round 6 bind spawn provenance --- packages/bash/bash-sandbox/src/helpers.ts | 17 ++++++++++------- .../bash/bash-sandbox/tests/sandbox.spec.ts | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index 1c3ea82eb2..d4949a93be 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -23,10 +23,11 @@ function isUsableWorkdir(path: string): boolean { } /** - * Attribute only executable-class failures with Node spawn provenance after - * independently ruling out the caller-owned cwd. With a usable cwd, these - * codes describe resolution, permissions, or loading of the provider's - * argv[0], including a script whose shebang interpreter is unavailable. + * Attribute only executable-class failures with positive Node 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, permissions, or loading + * of that argv[0], including a script whose shebang interpreter is unavailable. * @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. @@ -41,9 +42,11 @@ export function isRunnerSpawnFailure( 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' || (syscall !== 'spawn' && !syscall.startsWith('spawn '))) return false - if (path !== undefined && (typeof path !== 'string' || (path.length > 0 && path !== runnerProgram))) return false - return true + 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. */ diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index fa3c70caa9..cafd2f53e2 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -384,6 +384,8 @@ describe('isRunnerSpawnFailure', () => { const missingRunner = join(spillDir, 'definitely-missing-runner') const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) => Object.assign(new Error('spawn failed'), { code, syscall, path }) + const spawnErrorWithoutPath = (syscall: string) => + Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall }) expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false) @@ -392,12 +394,24 @@ describe('isRunnerSpawnFailure', () => { expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false) - expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(true) - expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', undefined), missingRunner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false) + expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false) expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false) }) + + it('accepts only syscall provenance compatible with the exact runner program', () => { + const runner = join(spillDir, 'runner with spaces') + const spawnError = (syscall: string, path?: string) => + Object.assign(new Error('spawn failed'), { code: 'ENOEXEC', syscall, path }) + + expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true) + expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false) + }) }) describe('classifyRunnerFailure', () => { From 666690afe91b1deb3d0794237c264ec58888239b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 16:55:11 +0800 Subject: [PATCH 067/108] fix(sandbox): round 7 align spawn evidence --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 10 ++-- .../feature/2026-07-06-sandbox.zh.md | 10 ++-- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 4 +- docs/core-data-structures/sandbox.zh.md | 4 +- packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 6 +-- packages/bash/bash-sandbox/README.zh.md | 6 +-- packages/bash/bash-sandbox/src/helpers.ts | 17 ++++--- .../tests/partial-landlock.spec.ts | 51 ++++++++++++++++++- .../bash/bash-sandbox/tests/sandbox.spec.ts | 43 ++++++++++++++-- pnpm-lock.yaml | 1 + 13 files changed, 126 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 0ffcc0cee3..463ecee62c 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: b037be29e00e997e3689199d57e78940f6657e60 -2026-07-06-sandbox.zh.md: 5863d3c62781031c57611589024cb8469961de95 +2026-07-06-sandbox.md: 79a42cbb62c73829d5ab2d0d7c12db6324e3e086 +2026-07-06-sandbox.zh.md: 800cd83cff57b304fdfcaf0628eb3f97f171304a diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index b037be29e0..79a42cbb62 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -38,7 +38,7 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. -Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner cannot spawn, the consumer reports the same infrastructure error from the spawn channel before any command starts. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. +Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner rejects with attributable `ENOENT` or `EACCES`, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The owner-derived runtime context states the current file policy without replacing those enforcement boundaries. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly. @@ -60,7 +60,7 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so missing, non-executable, or unloadable runners fail through the spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured script necessarily owns its interpreter startup before it applies its profile. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable fails through the attributable `ENOENT`/`EACCES` spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured script necessarily owns its interpreter startup before it applies its profile. The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. @@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection is runner-owned only when the caller-owned workdir is independently usable and the error carries Node spawn provenance for provider argv[0] plus an executable-class code; invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution and a synchronous background start convert runner-owned rejections to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection is runner-owned only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]; a bare `syscall: 'spawn'` without an exact error path, other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution converts runner-owned rejections to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessService` that synchronously throws the same provenanced shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls and unloadable-runner evidence, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, and malformed-runner negative controls, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. @@ -185,7 +185,7 @@ Costs and accepted limits: ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports an executable-class spawn failure for that argv[0]. Other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground and synchronous background-start failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. +- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessService` that synchronously throws the same provenanced `ENOENT`/`EACCES` shape makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 5863d3c627..800cd83cff 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -38,7 +38,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接 spawn 提供方返回的已包装 argv。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 -配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 无法 spawn,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 +配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。 被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的运行时上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 @@ -60,7 +60,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此 runner 缺失、不可执行或无法加载时会通过 spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器,再应用自身 profile。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本会通过可归因的 `ENOENT`/`EACCES` spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器,再应用自身 profile。 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 @@ -70,7 +70,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且错误带有 Node 为提供方 argv[0] 报告的 spawn 来源信息和可执行文件类错误码时,拒绝才会归因于 runner;无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行和同步启动后台任务时会将可归因于 runner 的拒绝转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,拒绝才会归因于 runner;没有精确错误路径的裸 `syscall: 'spawn'`、其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将可归因于 runner 的拒绝转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 @@ -117,7 +117,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照及 runner 无法加载证据的 spawn 层失败、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 +- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照、runner 缺失/不可执行/解释器缺失证据以及格式错误 runner 阴性对照的 spawn 层失败、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 @@ -185,7 +185,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可执行文件类 spawn 失败时,才能据此判定 runner 损坏。其他拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败与同步的后台启动失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 +- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 1a92643f24..0ce56adb0f 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -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 docs/core-data-structures/sandbox.md -sandbox.md: fd4861f3d89aecfd9bee8bbfcf7ac1abe15ac937 -sandbox.zh.md: 2aac7f6dfc5feeb9bf3192487a18eaaeef832e10 +sandbox.md: b931dca54afd1fdd1dcba5b8e7778feeedb7d15d +sandbox.zh.md: da44670232223458f0d1d7d588fa0a886a3fb932 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index fd4861f3d8..b931dca54a 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing, non-executable, or unloadable runner — including a script whose shebang interpreter is unavailable — rejects through the spawn channel rather than a stderr rule; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches. +The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable rejects through the spawn channel rather than a stderr rule when Node supplies attributable `ENOENT`/`EACCES` evidence; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Any direct spawn rejection of the returned argv proves the confined launch never started, but only an executable-class error with Node spawn provenance for provider argv[0] after the caller-owned workdir is independently verified usable carries infrastructure meaning and the original error as detail. Invalid or unusable workdirs, resource failures, unrelated syscalls, and unstructured rejections retain the consumer's ordinary command-start semantics. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Any direct spawn rejection of the returned argv proves the confined launch never started, but only `ENOENT` or `EACCES` with positive Node provenance for provider argv[0] after the caller-owned workdir is independently verified usable carries infrastructure meaning and the original error as detail. 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 consumer's ordinary command-start semantics. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy. Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 2aac7f6dfc..da44670232 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -139,10 +139,10 @@ interface ConfinedArgv { } ``` -面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此 runner 缺失、不可执行或无法加载(包括脚本的 shebang 解释器不可用)时会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名。 +面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此当 Node 提供可归因的 `ENOENT`/`EACCES` 证据时,缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时,任何拒绝都能证明受限启动从未开始;但只有在调用方拥有的 workdir 经独立验证可用,且错误属于带有 Node 为提供方 argv[0] 报告的 spawn 来源信息的可执行文件类错误时,该拒绝才具有基础设施含义,并以原始错误作为详细信息。无效或不可用的 workdir、资源失败、无关 syscall 和无结构拒绝仍保留消费方的普通命令启动语义。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时,任何拒绝都能证明受限启动从未开始;但只有在调用方拥有的 workdir 经独立验证可用,且 `ENOENT` 或 `EACCES` 带有明确指向提供方 argv[0] 的 Node 来源信息时,该拒绝才具有基础设施含义,并以原始错误作为详细信息。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留消费方的普通命令启动语义。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。 提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index bfd55d33a5..ce02372bdc 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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: 0fe8feaa0efda4111e997292a9741fffb6b55635 -README.zh.md: 43f6a209473839df7cbcfbefe4d11e29ff3de4b0 +README.md: 2f69ea66251f00c74779f1decc69abc6003a4398 +README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 0fe8feaa0e..2f69ea6625 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -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 failures are sandbox failures, never command failures.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and the error carries Node spawn provenance for provider argv[0] plus an executable-class code. This covers missing, non-executable, and unloadable runners, including a script whose shebang interpreter is unavailable; an invalid or unusable workdir, resource failure, unrelated syscall, or unstructured rejection retains the local executor's command-start failure semantics. Foreground execution and a synchronous background start throw `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. 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. +- **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. - **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/). @@ -72,7 +72,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 executable-class 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. +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: ` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix. #### Token effect @@ -86,5 +86,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. -- **A 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`. +- **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. - **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile. diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index 43f6a20947..fc4afb5544 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 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 为提供方 argv[0] 报告的 spawn 来源信息和可执行文件类错误码时,才会将拒绝归因于 runner。这样可以识别缺失、不可执行或无法加载的 runner,包括 shebang 解释器不可用的脚本;无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行与同步的后台启动会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 +- **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 工具描述则单独负责拒绝与升级引导。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 argv[0] 可执行文件类证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: `,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。 #### Token 影响 @@ -86,5 +86,5 @@ - **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 - **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 -- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 +- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index d4949a93be..357c73efa1 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -8,8 +8,8 @@ import { accessSync, constants, statSync } from 'node:fs' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' -/** Spawn codes that can describe an unavailable executable. */ -const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM']) +/** 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 { @@ -23,11 +23,14 @@ function isUsableWorkdir(path: string): boolean { } /** - * Attribute only executable-class failures with positive Node 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, permissions, or loading - * of that argv[0], including a script whose shebang interpreter is unavailable. + * 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. diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 36b2fb1898..f42d563202 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -10,7 +10,7 @@ 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 { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' +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' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' @@ -108,6 +108,55 @@ describe('partial Landlock runner-failure classification', () => { expect(accounting.size).toBe(0) }) + it('classifies a bare-name runner whose shebang interpreter is missing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-bare-sandbox-runner-')) + tempDirs.push(dir) + const runner = 'bare-missing-interpreter-runner' + await writeFile(join(dir, runner), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 }) + const bash = await setupConfiguredRunner(runner) + const request = { command: 'true', env: { PATH: dir } } + + const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`spawn ${runner} ENOENT`) + + const task = bash.start(bash.resolve(request)) + await task.done + expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`) + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + runnerFailed: true, + }) + }) + + it('keeps a real malformed executable ENOEXEC as an ordinary spawn failure', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-')) + tempDirs.push(dir) + const runner = join(dir, 'malformed-runner') + await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 }) + const bash = await setupConfiguredRunner(runner) + + const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) + expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((foreground as { path?: unknown }).path).toBeUndefined() + expect(foreground).not.toBeInstanceOf(SandboxUnavailableError) + + let background: unknown + try { + bash.start(bash.resolve({ command: 'true' })) + } catch (error) { + background = error + } + expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((background as { path?: unknown }).path).toBeUndefined() + expect(background).not.toBeInstanceOf(SandboxUnavailableError) + const accounting = (bash as unknown as { processFacts: Map }).processFacts + expect(accounting.size).toBe(0) + }) + it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])( 'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line', async (exitCode) => { diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index cafd2f53e2..11515c852c 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -214,7 +214,7 @@ describe('fail closed', () => { } }) - it('classifies synchronous ENOEXEC as runner loading failure in run() and start()', async () => { + it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => { const runner = join(spillDir, 'malformed-runner') const { ctx, bash } = await setup({}, argv => ({ argv: [runner, ...argv], @@ -223,7 +223,33 @@ describe('fail closed', () => { runnerFailureRules: RUNNER_FAILURE, })) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { - throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn', path: runner }) + throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' }) + }) + + const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error) + expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect(foreground).not.toBeInstanceOf(SandboxUnavailableError) + + let background: unknown + try { + bash.start(bash.resolve({ command: 'true' })) + } catch (error) { + background = error + } + expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect(background).not.toBeInstanceOf(SandboxUnavailableError) + }) + + it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => { + const runner = join(spillDir, 'unexecutable-runner') + const { ctx, bash } = await setup({}, argv => ({ + argv: [runner, ...argv], + enforcement: 'full', + denialSignatures: UNIX_SIGNATURES, + runnerFailureRules: RUNNER_FAILURE, + })) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { + throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner }) }) await expect(bash.run(bash.resolve({ command: 'true' }))) @@ -359,7 +385,7 @@ describe('classifyDenial', () => { }) describe('isRunnerSpawnFailure', () => { - it.each(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])( + it.each(['EACCES', 'ENOENT'])( 'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated', (code) => { const runner = join(spillDir, 'runner') @@ -368,6 +394,15 @@ describe('isRunnerSpawnFailure', () => { }, ) + it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])( + 'keeps unproven executable code %s ordinary despite synthetic argv[0] fields', + (code) => { + const runner = join(spillDir, 'runner') + const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner }) + expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false) + }, + ) + it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => { const missingWorkdir = join(spillDir, 'missing-workdir') for (const [, runner] of RUNNER_FORMS) { @@ -405,7 +440,7 @@ describe('isRunnerSpawnFailure', () => { it('accepts only syscall provenance compatible with the exact runner program', () => { const runner = join(spillDir, 'runner with spaces') const spawnError = (syscall: string, path?: string) => - Object.assign(new Error('spawn failed'), { code: 'ENOEXEC', syscall, path }) + Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path }) expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true) expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc3ed7e1f..95cd641b8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -823,6 +823,7 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:* version: link:../packages/context/workspace-context + packages/acp/acp: dependencies: '@agentclientprotocol/sdk': From c4e9893a87fedd69cf6d3a1435496b6ec4960c23 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 17:10:42 +0800 Subject: [PATCH 068/108] test(sandbox): cover POSIX no-shebang fallback --- .../tests/partial-landlock.spec.ts | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index f42d563202..e11193798c 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -132,27 +132,52 @@ describe('partial Landlock runner-failure classification', () => { }) }) - it('keeps a real malformed executable ENOEXEC as an ordinary spawn failure', async () => { + it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-')) tempDirs.push(dir) const runner = join(dir, 'malformed-runner') await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 }) const bash = await setupConfiguredRunner(runner) + const request = { command: 'true' } - const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) - expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) - expect((foreground as { path?: unknown }).path).toBeUndefined() + // 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. + const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value) expect(foreground).not.toBeInstanceOf(SandboxUnavailableError) - let background: unknown - try { - bash.start(bash.resolve({ command: 'true' })) - } catch (error) { - background = error + if (foreground instanceof Error) { + expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((foreground as { path?: unknown }).path).toBeUndefined() + + let background: unknown + try { + bash.start(bash.resolve(request)) + } catch (error) { + background = error + } + expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) + expect((background as { path?: unknown }).path).toBeUndefined() + expect(background).not.toBeInstanceOf(SandboxUnavailableError) + } else { + expect(foreground).toMatchObject({ + exitCode: 127, + signal: null, + sandbox: { mode: 'read-only', denied: false, enforcement: 'full' }, + }) + expect((foreground as { stderr: { text: string } }).stderr.text).toMatch(/not.*not found/) + + const background = bash.start(bash.resolve(request)) + await background.done + expect(background.status).toBe('completed') + expect(background.exitCode).toBe(127) + expect(background.signal).toBeNull() + expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + const output = background.readOutput().delta + expect(output).toMatch(/\[stderr\]\n[\s\S]*not.*not found/) + expect(output).not.toContain('spawn failed:') } - expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' }) - expect((background as { path?: unknown }).path).toBeUndefined() - expect(background).not.toBeInstanceOf(SandboxUnavailableError) + const accounting = (bash as unknown as { processFacts: Map }).processFacts expect(accounting.size).toBe(0) }) From 00d4349eb4fd2e37e9a610f4dd2dad9024aecbc6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 4 Aug 2026 17:14:58 +0800 Subject: [PATCH 069/108] feat(client): surface pending session interactions --- ...7-23-web-permission-and-approval.i18n.yaml | 6 +- .../2026-07-23-web-permission-and-approval.md | 6 +- ...26-07-23-web-permission-and-approval.zh.md | 6 +- apps/web/tests/built-boot.snapshot.ts | 9 +- apps/web/tests/plan-review.e2e.ts | 12 +- apps/web/tests/question-composer.e2e.ts | 9 ++ .../snapshots/plan-review/sidebar.expected.md | 1 + .../question-composer/sidebar.expected.md | 1 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 4 +- .../runtime/src/client/sessions/lineage.ts | 21 ++- .../runtime/src/client/sessions/manager.ts | 137 ++++++++++++------ .../runtime/src/client/sessions/pending.ts | 3 + .../runtime/src/client/sessions/service.ts | 10 +- packages/client/runtime/tests/manager.spec.ts | 83 ++++++++--- packages/client/test-runtime/src/sessions.ts | 1 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../tests/coverage-tails.spec.tsx | 2 +- .../ui-conversation/tests/diff-card.spec.tsx | 4 +- .../ui-conversation/tests/read-card.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../tests/terminal-card.spec.tsx | 4 +- .../client/ui-primitives/src/StateDot.tsx | 2 +- .../tests/conversation-ui.spec.tsx | 2 - packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.tsx | 1 + .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/rows/Rows.tsx | 39 ++++- .../client/ui-workspace/src/client/tree.ts | 14 +- .../client/ui-workspace/tests/rows.spec.tsx | 46 ++++-- .../client/ui-workspace/tests/tree.spec.ts | 12 +- .../tests/workspace-browser.spec.tsx | 2 +- 40 files changed, 334 insertions(+), 147 deletions(-) create mode 100644 apps/web/tests/snapshots/plan-review/sidebar.expected.md create mode 100644 apps/web/tests/snapshots/question-composer/sidebar.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml index 4d686731fe..3013474208 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -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 -2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 -2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +2026-07-23-web-permission-and-approval.md: 37fd3a04603747572f538e57a69441b32a51c947 +2026-07-23-web-permission-and-approval.zh.md: f895f66d052481962b91d6f0cb5bd7ebeeab06d0 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md index cd402a039e..37fd3a0460 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -16,13 +16,13 @@ The web host composes the same sandboxed product path as the acp-agent compositi The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. -Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-question, including the `plan-review` decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies plan-review intent separately, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable `PendingWait`; tracking clears per connection generation so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. ## Alternatives considered **Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. -**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. +**A session event for pending interactions instead of the live registries.** Rejected: answerable requests are transient interaction state, not durable session data — approval's `approval/asked`/`decided` audit pair already logs its durable half. Persisting requested frames would re-ask dead questions on replay. **Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. @@ -30,4 +30,4 @@ Client-side, `Session` gained `permissions` and `setPermission`, and approval an ## Consequences -Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. +Web sessions start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-question over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md index ce4964789b..f895f66d05 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -16,13 +16,13 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l 权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 -在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它单独分类 plan-review intent,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪按连接代次清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 ## 曾考虑的替代方案 **在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 -**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 +**用一个会话事件承载 pending 交互,而非实时注册表。** 不予采纳:可应答请求是瞬态的交互状态,而非持久的会话数据——审批的 `approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 **仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 @@ -30,4 +30,4 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l ## 后果 -Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 +Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-question 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index c452295d23..12487d0c43 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -7,7 +7,7 @@ // content from the keyless FixtureApiClient transport. // // Component behavior remains owned by per-package suites (SlotTestRuntime -// benches over src). This smoke additionally pins the resident approval +// benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' @@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') - // The resident approval fixture proves the assembled workspace plugin - // distinguishes a blocked running session from an ordinarily busy one. + // The resident fixture has both a question and an approval; composer routing + // exposes the question first, and the assembled workspace plugin mirrors that + // actionable wait instead of the underlying running state. const waitingTitle = await within(tree).findByText('Fixture 历史会话') const waitingRow = waitingTitle.closest('[role="treeitem"]') if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() - within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Waiting for answer') // Opening a session reaches chat content through the fixture transport. fireEvent.click(waitingTitle) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index e37e3954ab..8c2462d7ee 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // The waiting golden owns the decision card; the approved golden owns the // transcript the approval leaves behind — the state the card cannot see. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') const MODE = webSnapshotMode() @@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } await card.getByRole('button', { name: 'Approve' }).click() @@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Card gone; regular input restored. expect(await page.locator('[data-plan-review-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) @@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', + ]) }) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ebbaf759c4..6f865567bb 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. @@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => { await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0) + const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + if (MODE !== 'record') { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } // Squeezed card: the option rows are the capped card's scroll content, so @@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) + expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip // rendered as history (question tool row + DONE), composer takeover gone. @@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', + 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', ]) diff --git a/apps/web/tests/snapshots/plan-review/sidebar.expected.md b/apps/web/tests/snapshots/plan-review/sidebar.expected.md new file mode 100644 index 0000000000..0e184c2f6f --- /dev/null +++ b/apps/web/tests/snapshots/plan-review/sidebar.expected.md @@ -0,0 +1 @@ +- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]' diff --git a/apps/web/tests/snapshots/question-composer/sidebar.expected.md b/apps/web/tests/snapshots/question-composer/sidebar.expected.md new file mode 100644 index 0000000000..fcc2849e0f --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/sidebar.expected.md @@ -0,0 +1 @@ +- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected] diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..f5a71ec95a 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/client/runtime/README.md -README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: f24ab9f9c056b6c1136676146082e7aaaa23377f +README.zh.md: af3d5bb42e62818b969c9e0de814c1bcff4d9f37 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..f24ab9f9c0 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -8,6 +8,8 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while a question carrying the `plan-review` intent keeps that distinct status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. + `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. `WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..af3d5bb42e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -8,6 +8,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;带有 `plan-review` intent 的问题会保留这一独立状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 + `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 `WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0a384fffa2..2336ec9d9c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -59,7 +59,9 @@ export type { export type { ConversationHistoryProjection } from './session-history/history-fold.ts' export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' -export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' +export type { + PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, +} from './sessions/pending.ts' // Projection value store (session-projection RFC, push model): host-computed // whole values per key; domains ship projection support with zero client code. export type { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 4f26674420..115370488f 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -4,6 +4,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { PendingInteractionStatus } from './pending.ts' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { @@ -12,7 +13,7 @@ export interface TitledSessionSummary extends SessionSummary { projectionValues?: Readonly> } -/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ +/** One flattened session-list row with lineage depth and live pending interaction. */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -26,8 +27,8 @@ export interface SessionListEntry { cwd?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> - /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ - waitingApproval: boolean + /** User interaction currently blocking this session, derived from live mux frames. */ + pendingInteraction?: PendingInteractionStatus /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -37,10 +38,13 @@ export interface SessionListEntry { * follows the established input order; this projection never re-sorts a * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. - * @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). + * @param pendingInteractions - current manager-owned interaction status by session. * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet): SessionListEntry[] { +export function flattenLineage( + summaries: readonly TitledSessionSummary[], + pendingInteractions?: ReadonlyMap, +): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -64,7 +68,12 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti return } visited.add(s.sessionId) - out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) + const pendingInteraction = pendingInteractions?.get(s.sessionId) + out.push({ + ...s, + ...(pendingInteraction === undefined ? {} : { pendingInteraction }), + depth, + }) const kids = children.get(s.sessionId) if (kids === undefined) return for (const kid of kids) walk(kid, depth + 1) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b4bf12a0e3..18162647b4 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' +import type { PendingInteractionStatus } from './pending.ts' // Type-only merge edge: the title domain's client-namespace outlet declares // the 'title' projection key this manager projects into list rows (and any // useProjection('title') consumer reads). Zero value imports by construction. @@ -70,23 +71,30 @@ type SessionListMutation = /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } -/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ -const PENDING_BUFFER_CAP = 32 +/** Stable identity of a frame retained until an uninstantiated Session can consume it. */ +function bufferedRequestKey(envelope: RpcRequest): string | undefined { + const frame = envelope.payload + switch (frame.type) { + case 'approval/requested': return `a:${frame.approvalId}` + case 'question/requested': return `q:${envelope.rpcId}` + case 'session/queue': return 'queue' + default: return undefined + } +} /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { private readonly sessions = new Map() - /** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit - * history (cannot be backfilled on open), the one frame class that must not take the - * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these - * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ + /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history + * cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates + * compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */ private readonly pendingBuffers = new Map[]>() - /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open - * replays of the same requested frame). Manager-owned rather than read off Session instances - * because the sidebar must light up for sessions never instantiated. Cleared per connection - * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ - private readonly waitingApprovals = new Map>() + /** Outstanding answerable interactions per session, keyed by their stable request identity. + * Manager-owned rather than read off Session instances because the sidebar must light up for + * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds + * still-pending requests — and on session-removed. */ + private readonly pendingInteractions = new Map>() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -567,6 +575,26 @@ export class SessionManager { return this.listSnapshotCache } + /** Add or refresh one stable pending-interaction identity. */ + private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void { + let interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined) { + interactions = new Map() + this.pendingInteractions.set(sessionId, interactions) + } + if (interactions.get(key) === status) return + interactions.set(key, status) + this.notifier.markDirty() + } + + /** Settle one pending-interaction identity without disturbing sibling waits. */ + private resolvePending(sessionId: SessionId, key: string): void { + const interactions = this.pendingInteractions.get(sessionId) + if (interactions === undefined || !interactions.delete(key)) return + if (interactions.size === 0) this.pendingInteractions.delete(sessionId) + this.notifier.markDirty() + } + // ---- ConnectionController sinks (wired by boot) ---- /** @@ -606,43 +634,56 @@ export class SessionManager { } } } - // List-level waiting-approval bit (the sidebar amber dot): tracked here for - // every session, instantiated or not; approvalId keys make replays idempotent. + // List-level pending-interaction status (the sidebar amber dot): tracked + // for every session, instantiated or not; stable keys make replays idempotent. if (frame.type === 'approval/requested') { - let ids = this.waitingApprovals.get(frame.sessionId) - if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) - if (!ids.has(frame.approvalId)) { - ids.add(frame.approvalId) - this.notifier.markDirty() - } + this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval') } else if (frame.type === 'approval/resolved') { - const ids = this.waitingApprovals.get(frame.sessionId) - if (ids !== undefined && ids.delete(frame.approvalId)) { - if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) - this.notifier.markDirty() - } + this.resolvePending(frame.sessionId, `a:${frame.approvalId}`) + } else if (frame.type === 'question/requested') { + this.trackPending( + frame.sessionId, + `q:${envelope.rpcId}`, + frame.questions.length === 1 && frame.questions[0]?.intent?.kind === 'plan-review' + ? 'plan-review' + : 'question', + ) + } else if (frame.type === 'question/resolved') { + this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question/queue frames never hit history: buffer for replay on - // instantiation; everything else drops (not instantiated — history fully - // backfills on open). + // Answerable requests never hit history: retain each live identity until + // instantiation, compacting replay duplicates and resolutions so list + // status cannot outlive the PendingWait the user would need to answer. + // Queue is a latest-value snapshot; everything else drops because open + // backfills it from history. switch (frame.type) { case 'approval/requested': - case 'approval/resolved': case 'question/requested': - case 'question/resolved': case 'session/queue': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] - const prior = frame.type === 'session/queue' - ? buffer.findIndex(item => item.payload.type === 'session/queue') - : -1 - if (prior !== -1) buffer.splice(prior, 1) - buffer.push(envelope) - if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) + const key = frame.type === 'approval/requested' + ? `a:${frame.approvalId}` + : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue' + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior === -1) buffer.push(envelope) + else buffer[prior] = envelope this.pendingBuffers.set(frame.sessionId, buffer) return } + case 'approval/resolved': + case 'question/resolved': { + const buffer = this.pendingBuffers.get(frame.sessionId) + if (buffer === undefined) return + const key = frame.type === 'approval/resolved' + ? `a:${frame.approvalId}` + : `q:${frame.questionRpcId}` + const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) + if (prior !== -1) buffer.splice(prior, 1) + if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId) + return + } default: return } @@ -689,7 +730,7 @@ export class SessionManager { this.sessions.get(frame.sessionId)?.handleRemoved() } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone + this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -735,14 +776,14 @@ export class SessionManager { * The moment a connection generation dies (before any next-generation frame * can arrive — onConnected waits for the readiness handshake while replayed * frames flow from stream open, so clearing there would race the replay): - * drop generation-scoped live state. Approvals resolved while disconnected - * send no frame, so the stale bits and the buffered answerable frames must - * not survive into the next generation — the mux-open replay re-adds every - * still-pending question with its live rpcId. + * drop generation-scoped live state. Interactions resolved while disconnected + * send no frame, so stale statuses and buffered answerable frames must not + * survive into the next generation — mux-open replay re-adds every still-pending + * request with its live rpcId. */ handleDisconnected(): void { - if (this.waitingApprovals.size > 0) { - this.waitingApprovals.clear() + if (this.pendingInteractions.size > 0) { + this.pendingInteractions.clear() this.notifier.markDirty() } for (const [sessionId, buffer] of [...this.pendingBuffers]) { @@ -855,7 +896,15 @@ export class SessionManager { ...(projectionValues === undefined ? {} : { projectionValues }), } }) - const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) + const pendingInteractions = new Map() + for (const [sessionId, interactions] of this.pendingInteractions) { + const statuses = [...interactions.values()] + // The composer selects the first question ahead of approval. Mirror that + // answer order so the sidebar names the interaction the user can act on. + const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] + if (status !== undefined) pendingInteractions.set(sessionId, status) + } + const fresh = flattenLineage(merged, pendingInteractions) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -863,7 +912,7 @@ export class SessionManager { && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth - && prev.waitingApproval === entry.waitingApproval + && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index ba69a6951e..1383faea35 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -15,6 +15,9 @@ export interface PendingPayloads { /** Pending-interaction discriminant (the keys of PendingPayloads). */ export type PendingKind = keyof PendingPayloads +/** Session-list summary of the user action currently blocking progress. */ +export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question' + /** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ export type PendingInteraction = { [K in PendingKind]: PendingWait }[PendingKind] diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 47ee641053..9399f594d3 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -33,6 +33,7 @@ import type { ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' +import type { PendingInteractionStatus } from './pending.ts' import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' @@ -48,8 +49,8 @@ export interface SessionSummary { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' running: boolean - /** An approval question is pending on this session (sidebar amber-dot state). */ - waitingApproval: boolean + /** User interaction currently blocking this session (sidebar amber-dot state). */ + pendingInteraction?: PendingInteractionStatus /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -613,9 +614,11 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, - waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.pendingInteraction === undefined + ? {} + : { pendingInteraction: entry.pendingInteraction }), ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), @@ -643,7 +646,6 @@ export class SessionsService implements ISessions { parentId: address.parentSessionId, origin: 'subagent', running: child.activity === 'running', - waitingApproval: false, blank: false, updatedAt: 0, } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index f6d7baf318..118bf17d49 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -40,6 +40,7 @@ describe('instances', () => { const manager = new SessionManager(api) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) const session = manager.get(S1) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) @@ -47,16 +48,26 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) - it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { + it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) - // 40 distinct question frames for an uninstantiated session: only the newest 32 survive. + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) } - const pending = manager.get(S1).getSnapshot().pending - expect(pending).toHaveLength(32) - expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + for (let i = 0; i < 40; i++) { + manager.handleMuxEnvelope({ + rpcId: `r${i}` as never, + payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, + }) + } + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + expect(manager.get(S1).getSnapshot().pending).toEqual([]) + }) + + it('drops buffered answerable requests on session removal', () => { + const manager = new SessionManager(new FakeApiClient()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) @@ -862,48 +873,76 @@ describe('connected generation', () => { }) }) -describe('waiting-approval list bit', () => { - it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { +describe('pending-interaction list status', () => { + it('tracks approval requests through replay and resolution without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Mux-open replay of the same question (same approvalId) is idempotent. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) - it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + it('classifies ordinary questions and plan reviews, then clears by question rpcId', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ + rpcId: 'q2' as never, + payload: { + type: 'question/requested', + sessionId: S1, + questions: [{ id: 'plan', question: 'Approve?', detail: '# Plan', intent: { kind: 'plan-review', approve: 'Approve' } }], + }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + }) + + it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ + rpcId: 'q1' as never, + payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, + }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') + manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) - manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) - // Removed sessions drop their bit outright. - manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() + + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => { + it('drops stale status at generation death before replay re-adds live interactions', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Generation death clears (resolved-while-disconnected questions send no frame)… manager.handleDisconnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() // …and a replayed frame arriving before onConnected (stream open precedes // the readiness handshake) survives the later handleConnected untouched. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleConnected() - expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 1d20a512f5..e313b63fd2 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -222,7 +222,6 @@ export class TestSessions implements ISessions { id, displayTitle: fixture.id, running: false, - waitingApproval: false, blank: false, updatedAt: this.records.size + 1, ...fixture.summary, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..6ebbbed744 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/client/ui-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: 0d00eac1db5aed7feec9bb714fe3d8976cd39943 +README.zh.md: 4219950a9d51eb1037051ca00d61fa0d5ffa6fd2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..0d00eac1db 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..4219950a9d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index f07104ba7b..8c4af6a921 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 8368c7cca1..adf83a481d 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -244,7 +244,7 @@ describe('bash sample row', () => { return createSnapshotStore({ ids: [SID], byId: { - [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, + [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 445b875109..c92e43db6c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -93,7 +93,7 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 0d0fd46407..60103ec1f8 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -153,7 +153,7 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -306,7 +306,7 @@ describe('DetailsPanel diff Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index f8ecb8b73c..4dcdb6c766 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, @@ -254,7 +254,7 @@ describe('DetailsPanel Output section (read)', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 312ec88642..f9bd3fa3c1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -92,10 +92,10 @@ function mount( } = {}, ) { const root = sid('root') - const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 } + const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 } const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', - running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2, + running: false, blank: options.summaryBlank ?? false, updatedAt: 2, ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index d4be802a34..f655979518 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -342,7 +342,7 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, @@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 77f9c0a794..e83851edfc 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './StateDot.module.css' -/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ +/** Four-color state semantic (green done / amber user-attention / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' /** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index b6c02fcfb7..e0610d3379 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -56,7 +56,6 @@ function props( displayTitle: 'worker', running: true, blank: false, - waitingApproval: false, updatedAt: Date.now(), }, }, @@ -83,7 +82,6 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { displayTitle: id, running: false, blank: false, - waitingApproval: false, updatedAt, } } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 374efd0f58..43d753fae5 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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/client/ui-workspace/README.md -README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 -README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab +README.md: 855bcdc7fa1850ee30a1887add10dc019d9887e2 +README.zh.md: acb35ab169f10444c26ae6d68aefda9c5b8703df diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 17105f9d70..855bcdc7fa 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. -Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. +Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -30,5 +30,5 @@ None; this package neither assembles nor sends a provider request. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. - **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. -- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. +- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b40b946927..acb35ab169 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 -Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 +Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -30,5 +30,5 @@ Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 - **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 -- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 +- **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 3607dc8640..233f6571ec 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -306,6 +306,7 @@ function SearchResults({ result={result} currentId={list.current} onOpen={open} + t={t} /> ))}
diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index e64127a70d..b9e06a6ae2 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -47,6 +47,8 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', + 'status.planReview': '计划待审', + 'status.waitingAnswer': '等待回答', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -105,6 +107,8 @@ export const en = { 'status.running': 'Running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', + 'status.planReview': 'Plan awaiting review', + 'status.waitingAnswer': 'Waiting for answer', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index e961919498..836325076b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -166,14 +166,29 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** Session status presentation; approval waiting outranks the underlying running state. */ -function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { - if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } +/* v8 ignore next 3 -- closed-union backstop; only reached if the status is forged */ +function assertNever(value: never): never { + throw new Error(`unknown pending interaction: ${String(value)}`) +} + +/** Session status presentation; pending user interaction outranks the running state. */ +function sessionStatus( + node: Pick, + t: RowTranslate, +): { state: StateDotState; label: string } { + switch (node.pendingInteraction) { + case 'approval': return { state: 'warning', label: t('status.waitingApproval') } + case 'plan-review': return { state: 'warning', label: t('status.planReview') } + case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case undefined: break + /* v8 ignore next -- closed PendingInteractionStatus union */ + default: return assertNever(node.pendingInteraction) + } if (node.running) return { state: 'ongoing', label: t('status.running') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and approval/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -215,14 +230,17 @@ export interface RowDragProps { * @param props.result - merged local/content search row. * @param props.currentId - selected session id. * @param props.onOpen - open the selected session. + * @param props.t - Workspace-browser translation seat. * @returns the result button. */ -export function SearchResultItem({ result, currentId, onOpen }: { +export function SearchResultItem({ result, currentId, onOpen, t }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void + t: RowTranslate }) { const selected = result.id === currentId + const status = sessionStatus(result, t) return (