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 4fd61c2891..8dcdb115ac 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: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 -2026-07-30-web-config-plane.zh.md: 184299cf0c44b992e55a268c990b468c6d13f947 +2026-07-30-web-config-plane.md: 5970cfcea2e577998a235d08769ba497f5e8c18b +2026-07-30-web-config-plane.zh.md: dbfe1d8c8b635a607ef1c0798d972528a4712dfa 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 6d1a8c242c..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,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 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. +**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. ## 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, configured, and delete-confirmation 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 removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. 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 explicit removal of a provider's retained credential. +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, configured, and delete-confirmation 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 removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. 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. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. 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 184299cf0c..dbfe1d8c8b 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-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 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,并在其完成前阻止重复提交。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 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`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`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`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 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 c8aae3b6dc..7f15a55333 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: a8883ae641c6561765575b6a0e547ba919040dd9 -2026-07-06-sandbox.zh.md: c42ab0f94bf5579f7a2cd8361f3fbb0e6eca8949 +2026-07-06-sandbox.md: aed5ac1ceb02130ce97a8c83c0f77869fdc32146 +2026-07-06-sandbox.zh.md: db95b1a5b7a7cae1e0fcdd8deba9dcb6ad020a67 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a8883ae641..aed5ac1ceb 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 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 pending policy 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. @@ -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,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 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. 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; 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; 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. 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`, 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` 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,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 policy-context ordering and inbox reconciliation. +- **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, 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 policy-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 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 @@ -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). @@ -172,19 +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 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. - **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. -- **Policy-context history is append-only.** A policy switch queues a complete superseding message after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message. -- **Older policy messages remain in history.** Each full message explicitly supersedes earlier policy context, so replay and compaction need only retain the latest entered message. +- **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 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?** 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 c42ab0f94b..db95b1a5b7 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 启动。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束,但会以自身错误文本拒绝每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。 +这一替换对 `ctx.bash` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 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 以可归因的 `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 服务,而是显式选定其部署模式。 @@ -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 + 工作区根目录)以及 `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 + 工作区根目录)以及 `SandboxPolicy`(提供给约束后端的子集)。 策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 @@ -60,17 +60,17 @@ 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 退出,精确匹配的部分强制执行通知仍只是信息。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 退出且不 exec。 +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 参数,而 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 参数、致命前缀和部分强制执行通知,而 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 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 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)负责该上下文的理由与边界。 @@ -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,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及策略上下文排序与 inbox 协调。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 +- **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试证明注册表 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 仓库自己的流水线)。 @@ -172,19 +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 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 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` 运行根本不触及提供方:此时授权(而非探测)是权威。 -- **策略上下文历史仅追加。** 策略切换会在保留的历史之后排队一条用于取代先前消息的完整消息,从而保留稳定的系统与对话前缀;状态不变时不添加消息。 -- **旧策略消息仍保留在历史中。** 每条完整消息都会明确取代更早的策略上下文,因此回放与压缩(compaction)只需保留最新进入步骤的消息。 +- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 +- **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。 +- **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** Runner 失败在分类中优先于拒绝:匹配包装的 `runnerFailureSignatures` 的失败运行意味着命令从未运行——前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带 runner 的 stderr 行,后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。损坏的沙箱永远不会被误判为命令执行失败,且命令永远不会无约束运行。 +- **如何区分损坏的沙箱与失败的命令?** 提供方 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/.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 c16a2bfc63..37839002ae 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: a8e74fb1539348535f89f49d7c6d56b2bab79278 +2026-07-24-web-session-model-selector.md: 05e923fb3b5df72485ffb8d6ff5aa0104b19a8a0 +2026-07-24-web-session-model-selector.zh.md: bdfa2f856a799cc86600650829a2df62a7dfdc18 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..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,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. 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. -`@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 a8e74fb153..bdfa2f856a 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 不会合成未列出行。两个前门有意对这一状态给出不同回答:TUI 仍把未列出的当前模型渲染为独立一行并标记为当前,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 正是编辑目录所在的前门,因此用户刚刚删除的目标应当读作一个有待作出的决定,而不是一项可以保留的选择;TUI 只在已存在的模型中挑选,没有这类编辑需要调和。这一代价真实存在且已被接受——显示未设置标签的 Web composer 仍会发送到实际路由的目标——这一分歧是有意为之,而不是一处遗漏的迁移。精确解析决定路由与显式推理强度是否可用。选择操作通过 `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/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index c6c6e627bd..aea33f14f1 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -2,7 +2,6 @@ // the same locale-aware, in-page risk confirmation. Zero model calls: the // scenario boots the shipped Web composition and exercises the real // permission projection, client command path, HTTP RPC, and pushed update. -import { mkdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -12,27 +11,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' - -/** - * connectFreshWorkspace twin over the product default Chinese locale (the - * shared helper's anchors assume the English page every other scenario - * boots; this scenario deliberately keeps zh, so the localized picker - * copy is the anchor set). - */ -async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { - mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - const pathInput = dialog.getByRole('textbox', { name: '编辑路径' }) - await pathInput.fill(join(root, name)) - await pathInput.press('Enter') - await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') - .waitFor({ timeout: 15_000 }) -} +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index b757af08d7..8c81f55810 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -104,6 +104,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await input.press('Enter') const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) await planButton.waitFor({ timeout: 10_000 }) + // The golden encodes an empty composer, and the button arriving does not + // mean the submitted text is gone yet: under load the capture caught a + // textbox still holding `/plan`. + await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) const planStyle = await planButton.evaluate((element) => { diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aa64816420..aac2c4806c 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -126,9 +126,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/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 1ec36454d0..51fa84af3d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -12,7 +12,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -22,6 +22,7 @@ import { const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md') 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', () => { @@ -160,7 +161,60 @@ 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')) + // 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() + 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) + 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).toContain('maxTokens: 64000') + expect(document).not.toContain('id: deepseek-v4-flash') + + await page.keyboard.press('Escape') + // A connected Workspace is what puts a live composer — and its model + // trigger — on the page; the scaffold boots without one. + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') + + 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', 'welcome.expected.md']) + await assertFixtureInventory( + SNAPSHOT_DIR, + ['missing.expected.md', 'models.expected.md', 'welcome.expected.md'], + ) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 2f50269455..3cb0b95dfe 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -240,12 +240,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/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index c95c6b8de6..8d544cefc1 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -26,8 +26,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 Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok 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 a9fb7901d7..66d5b6f9ba 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: Workspace Write"': Workspace Write - 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/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 0f261e5474..ed42dfec84 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 diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 0554b95404..815acaef1d 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -51,8 +51,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: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok 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..c71c1981ac --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -0,0 +1,70 @@ +- 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": + - /placeholder: 模型 ID + - text: deepseek-v4-pro + - textbox "显示名称 1": + - /placeholder: 显示名称 + - text: DeepSeek-V4-Pro + - button "容量 1": + - img + - button "删除模型 1": + - img + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: private-preview + - textbox "显示名称 2": + - /placeholder: 显示名称 + - text: Private Preview + - button "容量 2" [expanded]: + - img + - button "删除模型 2": + - img + - text: 上下文窗口 + - textbox "上下文窗口 2": + - /placeholder: 1M + - text: "131072" + - text: 最大输出 token 数 + - textbox "最大输出 token 数 2": + - /placeholder: 256K + - text: 64K + - 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 d638821c45..9338167f2c 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -47,8 +47,8 @@ - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only -- 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 9e78cca0f8..f24aaac77c 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.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/support.ts b/apps/web/tests/support.ts index e42628c15b..1b7b67aab3 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -88,6 +88,28 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo .waitFor({ timeout: 15_000 }) } +/** + * {@link connectFreshWorkspace} over the product default Chinese locale: the + * English helper's anchors assume the locale every other scenario boots, so a + * scenario that deliberately keeps zh needs the localized picker copy. + * @param page - the browser page under test. + * @param root - workspace parent directory. + * @param name - directory created under `root` and connected. + */ +export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { + mkdirSync(join(root, name), { recursive: true }) + await page.getByRole('button', { name: '选择工作区' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + const pathInput = dialog.getByRole('textbox', { name: '编辑路径' }) + await pathInput.fill(join(root, name)) + await pathInput.press('Enter') + await dialog.getByRole('button', { name: '打开', exact: true }).click() + await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') + .waitFor({ timeout: 15_000 }) +} + /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */ export async function saveFailureShot(page: Page, name: string): Promise { const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d9771d6f44..33e6ef70ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -228,7 +228,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` @@ -247,7 +247,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:35`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-cli-demo` @@ -650,7 +650,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 @@ -672,6 +672,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 } ``` @@ -1062,17 +1064,18 @@ 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}. Consumers classify spawn rejection; only + * attributable `ENOENT` or `EACCES` with runner argv[0] provenance becomes an + * 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. */ @@ -1080,7 +1083,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 aad6ec34e5..46f6083630 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1111,7 +1111,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 a6985853f0..292d24d818 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: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: ff44db13115073f3a77d2bda575cc91ec41da418 +sandbox.md: b931dca54afd1fdd1dcba5b8e7778feeedb7d15d +sandbox.zh.md: a2334148e0237db2309c77437cf0681586e2fb5a diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..b931dca54a 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 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. 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. 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 ff44db1311..a2334148e0 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 报告的「命令不存在」和「不可执行」形式。这使得可执行的自定义运行器拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 +面向运维人员的本地提供方配置键仍为 `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`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 +`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/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..75c3218f4b --- /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: 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 new file mode 100644 index 0000000000..b3be42d221 --- /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`; the then-bash-backed filesystem search also 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, and an assembled keyless scenario pins the surviving bash path. Filesystem search has since moved to packaged ripgrep through the subprocess seam and no longer crosses sandboxed bash. + +## 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. + +At the time of the incident, filesystem search added a second attribution error. Its bash-backed `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 plus shared foreground/background classification and assembled replay coverage closed the surviving sandbox attribution gap. Before this fix was reconciled with current `master`, filesystem search moved to packaged ripgrep through `ctx.subprocess`; the obsolete bash-adapter patch and tests were dropped instead of reintroducing the old architecture. + +## 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. In the incident-era search implementation, 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 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 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 + +- 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..7a057d4405 --- /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`;当时仍由 bash 支撑的文件系统搜索还会用 `SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据,并由一个组装后的无密钥场景固定仍然存在的 bash 路径。文件系统搜索后来已改为通过 subprocess seam 运行打包的 ripgrep,不再经过沙箱化 bash。 + +## 概述 + +原生 launcher 契约区分两类 stderr 行。内核只能部分强制执行时,会精确打印 `landlock-run: partial enforcement (older Landlock ABI)`,然后继续执行子进程。launcher 失败则打印另一行 `landlock-run:` 诊断,在不执行子进程的情况下以 125 退出。 + +harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情况。消费方只要发现非零退出同时携带该子串,就会归类为 runner 失败。因此,子进程的退出状态被错误地关联到 launcher 的信息性行:`false`、ripgrep 无匹配时的退出码 1、无效 pattern 的退出码 2,乃至由子进程自行选择的退出码 125,都可能在约束与执行均成功的情况下被错误归因为沙箱故障。 + +事故发生时,文件系统搜索又造成第二处归因错误。当时由 bash 支撑的 `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 无匹配场景复现了故障。 +- 结构化规则、前台与后台共享的分类逻辑和组装后的回放覆盖共同弥补了仍然存在的沙箱归因缺口。本修复与当前 `master` 对齐前,文件系统搜索已改为通过 `ctx.subprocess` 运行打包的 ripgrep;合并时删除了过时的 bash 适配器补丁与测试,而没有把旧架构重新引入。 + +## 根因 + +公开的沙箱结果类型只能表达一组子字符串。它无法表示 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 仍仅依据签名。 +- [`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,覆盖仅带通知的子进程退出码 1、2、125,普通子进程退出码 126、127,带门控的致命诊断、权限拒绝,以及前台/后台一致性。 +- `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归。 + +## 教训 + +- 进程归因需要多项独立证据同时成立;共享前缀不是协议。 +- 信息性诊断与致命诊断可以共享同一命名空间,因此排除规则必须精确且范围狭窄,同时对未知的致命行保持失败关闭。 +- 适配器必须保留下层 seam 所拥有的结构化失败,而不能用自身最接近的通用类别将其替换。 +- 平台相关行为需要在原生边界放置确定性的模拟实现,并覆盖一条组装后的产品路径;会自行跳过的真实内核测试无法独自固定该回归。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index fb446b3e98..07b3a398dd 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: 45fe23275e6236d5210ba32622b51457d170aaeb +README.md: ffde0057304856b7c7718e3dd1f4743c48ee193f +README.zh.md: 58635af16212f990d0f4e2621f4bfed437631645 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 45fe23275e..58635af162 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..af834b885d --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# 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: + 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..7a958bb6de --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -0,0 +1,13 @@ +# 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: + 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 100dbd8d73..225b033b56 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -49,6 +49,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 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' @@ -176,6 +177,32 @@ const SCENARIOS: Scenario[] = [ // newline and one recording replays on every host. pwshOnly: 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, + }, + // 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 new file mode 100644 index 0000000000..dd0a2624f1 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -0,0 +1,41 @@ +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 for deterministic runner classification. Its default + * launch reproduces older-ABI Landlock; an explicit scenario flag selects a + * 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 { + 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', + '-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/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/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/examples/package.json b/examples/package.json index b2e2dd90ac..3975d5573b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-pwsh-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/knip.json b/knip.json index 46fbff979e..22e2b09fdb 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", diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index d4fafc6eaf..bdcf985216 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: c210333a6e757cb22ce02ec206dd4158ad91011a +README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b +README.zh.md: 5d3c1c2cd692bb87d5a759a0a6f9628a3f065863 diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 284d5df764..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): contract constants. +- `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 c210333a6e..5d3c1c2cd6 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):契约常量。成功完成 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..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`); 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 @@ -19,8 +19,8 @@ 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 `landlock-run: ` fatal line to attribute launcher failure. - `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise. ## Report lines diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 53de86122f..7a4349a5ca 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -23,9 +23,10 @@ export const LAUNCHER_BIN = 'landlock-run' /** * 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 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/launcher.test.js b/native/landlock-run/test/launcher.test.js index 4ab0070e1c..55385d2156 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -24,6 +24,8 @@ import { 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') { @@ -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(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_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(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..4ed49be02a 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 @@ -216,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) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -261,8 +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 _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): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _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 640eb1810f..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: 035a8ad2401ca608d264049d454359eda7b2b9a7 -README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb +README.md: 2f69ea66251f00c74779f1decc69abc6003a4398 +README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 035a8ad240..2f69ea6625 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 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 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). 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). 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 cee27a9baa..fc4afb5544 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 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **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 在执行时失败,此后端会提供第一行 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 a98f47216e..357c73efa1 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -1,18 +1,61 @@ /** - * Internal shell-quoting and sandbox-result classification helpers. + * Internal sandbox-result classification helpers. * * @module @deepseek-ai/dsh-bash-sandbox/helpers */ +import { accessSync, constants, statSync } from 'node:fs' import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} /** - * 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. + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. */ -export function shellQuote(text: string): string { - return `'${text.replaceAll("'", String.raw`'\''`)}'` +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string } /** @@ -26,13 +69,37 @@ 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())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined } /** diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index f46bad4006..eadbae9fef 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,21 +1,29 @@ /** * 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 */ 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 { + ConfinedArgv, + 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' -import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — @@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { mode: ConfinedSandboxMode enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] + runnerProgram: string | undefined + workdir: string }>() constructor(ctx: Context, config: Config) { @@ -83,11 +93,22 @@ 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 }) - // 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]) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) } return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } } @@ -96,11 +117,29 @@ 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 = super.start({ ...spec, command: confined.command }) - const { enforcement, denialSignatures, runnerFailureSignatures } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + // LocalSubprocessService reports provenanced ENOENT/EACCES through async + // `done` rejection; this covers alternatives that throw that shape synchronously. + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) return proc } @@ -108,12 +147,15 @@ 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, spawnFailed: boolean, 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 = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), @@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr) + super.onProcessDone(proc, stderr, spawnFailed, 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[] - runnerFailureSignatures: readonly string[] - } { - 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, - } + 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 new file mode 100644 index 0000000000..23546e5d92 --- /dev/null +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -0,0 +1,270 @@ +/** + * Deterministic real-process proofs for runner classification: the real local + * provider and sandbox bash executor exercise direct runner-spawn failures + * and a POSIX fake Landlock launcher that prints its notice 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 { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +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_PREFIX = 'landlock-run: ' +const FATAL = `${FATAL_PREFIX}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(fatalExit?: number): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-')) + tempDirs.push(dir) + const launcher = join(dir, 'landlock-run') + 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' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;; + esac +done +printf '%s\\n' '${NOTICE}' >&2 +${fatalBranch}exec "$@" +`, { mode: 0o755 }) + return launcher +} + +async function setup(fatalExit?: number): 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(fatalExit), + } + 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 +} + +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 +} + +describe('partial Landlock runner-failure classification', () => { + 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) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + 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.each(['bare-name', 'relative'] as const)( + 'classifies a %s runner whose shebang interpreter is missing', + async (form) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-')) + tempDirs.push(dir) + const filename = 'missing-interpreter-runner' + const runner = form === 'bare-name' ? filename : `./${filename}` + await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 }) + const bash = await setupConfiguredRunner(runner) + const request = form === 'bare-name' + ? { command: 'true', env: { PATH: dir } } + : { command: 'true', workdir: 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) + // Empirically, Darwin and Linux Node 24 preserve the passed bare/relative + // argv[0] in this spawn error rather than resolving it to an absolute path. + expect((error as Error).message).toContain(`spawn ${runner} ENOENT`) + + const task = bash.start(bash.resolve(request)) + await task.done + expect(task.status).toBe('killed') + 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 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' } + + // 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) + + 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.length).toBeGreaterThan(0) + + 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.startsWith('[stderr]\n')).toBe(true) + expect(output.length).toBeGreaterThan('[stderr]\n'.length) + expect(output).not.toContain('spawn failed:') + } + + 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) => { + 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.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(`${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(`${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(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) + }) + + 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 ['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) + } + }) + + 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(LAUNCHER_FAILURE_EXIT) + 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..5bcf68df2d 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,8 @@ 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 type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +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-')) @@ -30,12 +31,19 @@ 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 + +/** 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, runnerFailureSignatures: RUNNER_FAILURE }) + ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }) /** * Boot a context with a recording fake `ctx.sandbox` (behavior injectable @@ -90,15 +98,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, runnerFailureSignatures: 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 +162,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 +171,120 @@ 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) + }) + + 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') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }, + ) + + 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('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], + 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' }) + }) + + 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, + })) + // This pins an alternative SubprocessService's synchronous seam, not the + // shipped local behavior. + 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' }))) + .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', () => { @@ -233,15 +386,134 @@ describe('classifyDenial', () => { }) }) +describe('isRunnerSpawnFailure', () => { + it.each(['EACCES', 'ENOENT'])( + '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) + }, + ) + + 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) { + 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 }) + 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) + 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(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: 'ENOENT', 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', () => { - 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('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: ['', ' ', '\t'] }] + expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined() + expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined() + }) + + it('keeps valid fatal signatures active beside an ignored empty entry', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const fatal = 'landlock-run: ruleset creation failed' + const rules = [{ + allowedExitCodes: [125], + fatalSignatures: ['', ' ', 'landlock-run: '], + informationalLines: [notice], + }] + expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal }) + }) + + 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 }) }) }) 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') @@ -253,25 +525,66 @@ 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' }) }) }) describe('background sandbox facts', () => { - it('stamps facts and releases accounting when background spawn fails', 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 })) + 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', + 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 () => { + 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.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) + expect(task.readOutput().delta).toContain('spawn failed: undefined') + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'full', + }) }) it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => { @@ -284,7 +597,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 +628,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/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/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' } } diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 778c075cef..9621610052 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: 27fb7b936b796b956f7348fa776856180350bb56 -README.zh.md: 06dbcc21c31c8ed9fd72d3c07d43c8db1bcca0bb +README.md: bbc834db9489941c171aea1cb4e6dadb6f24d211 +README.zh.md: 065a6b771dbd7eea87f0c632a6dd9f0fde6c0100 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 27fb7b936b..bbc834db94 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`). For ordinary sessions, 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. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, 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. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. 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 06dbcc21c3..065a6b771d 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` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 +模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。 diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index 13688823fd..b19242fb44 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -198,8 +198,7 @@ white-space: nowrap; } -.description, -.unlisted { +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -208,10 +207,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 2ad7159044..343243a28c 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -174,8 +174,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 = () => { @@ -189,9 +194,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} @@ -277,9 +280,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 86091f4ede..22ac71fc37 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -51,9 +51,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..f2b8bf1f01 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -1,11 +1,19 @@ -/** `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 = { 'command.description': '选择本会话使用的模型', - 'option.unlisted': '{group} · 未列入目录', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', + 'trigger.selectAria': '选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', @@ -16,7 +24,6 @@ export const zh = { 'error.action': '模型操作失败:{message}', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', - 'option.currentUnlisted': '当前模型 · 未列入目录', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -27,9 +34,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 +47,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 61a8f6edd0..45df8ab38e 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -111,6 +111,29 @@ describe('ModelSelect reasoning effort', () => { .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() + }) + it('renders no Agent-bound control for an addressed subagent session', () => { const load = vi.fn() render(_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), and a localized confirmation dialog must complete before the page submits that destructive unset. +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), `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, while 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), and a localized confirmation dialog must complete before the page submits that destructive unset. 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 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. 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 @@ -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`/`maxTokens`; 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 a467b65da0..40da5b52f6 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),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 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),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `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 与显示名称,其上下文窗口与最大输出 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 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -20,7 +20,6 @@ ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.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](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;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..57221cc93a --- /dev/null +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -0,0 +1,364 @@ +/** + * 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 { useState } from 'react' +import type { ReactNode } from 'react' +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 + +/** 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 CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const + +/** + * 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 parseCapacity(text: string): number | undefined { + const trimmed = text.trim() + if (trimmed.length === 0) return undefined + const match = CAPACITY_PATTERN.exec(trimmed) + if (match === null) return Number.NaN + const suffix = match[2]?.toLowerCase() + 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. + 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 parseCapacity}; a count that is not a whole number of + * thousands stays written out. + * @param value - stored capacity. + * @returns the field text. + */ +export function formatCapacity(value: number): string { + if (!Number.isInteger(value) || value <= 0) return String(value) + 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) +} + +/** 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' + | 'modelMaxTokensInvalid' +} + +/** 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()) { + // Compared trimmed: surrounding whitespace is a paste artifact the adapter + // would never match, and an untrimmed compare lets `model ` slip past the + // duplicate check against its own twin. + const id = model['id'] + const trimmed = typeof id === 'string' ? id.trim() : undefined + if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' } + if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' } + seen.add(trimmed) + 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' } + } + const maxTokens = model['maxTokens'] + if (maxTokens !== undefined + && (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) { + return { index, key: 'modelMaxTokensInvalid' } + } + } + 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 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. */ + 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 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 { + // 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 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`. + // + // 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: CatalogField, 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 => { + setEditing((current) => { + const next = new Map() + for (const [key, text] of current) { + const at = rowOf(key) + if (at === index) continue + // 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 + }) + props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) + } + + const reset = (): void => { + setEditing(new Map()) + setExpanded(new Set()) + props.onReset() + } + + 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')} + + {props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')} + +
+ {props.overridden + ? ( + + ) + : null} +
+ {props.models.length === 0 + ?

{props.t('modelsEmpty')}

+ : ( +
+ {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) + }} + /> + + +
+ {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 193c291524..6b87dbefe3 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; @@ -8,19 +18,23 @@ .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; + font-size: 14px; + line-height: 22px; color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-state-warn-label); } @@ -31,9 +45,11 @@ 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(--dsw-alias-border-l2); border-radius: 12px; @@ -41,7 +57,6 @@ display: flex; flex-direction: column; gap: 12px; - background: var(--dsw-alias-bg-layer-3); } .rowHead { @@ -51,38 +66,59 @@ } .rowName { - font-size: 15px; - font-weight: 600; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .rowActions { display: inline-flex; - gap: 8px; + align-items: center; + gap: 4px; margin-left: auto; } -.primaryButton { +/* `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: 999px; - padding: 8px 18px; + border-radius: 18px; + font: inherit; + font-size: 14px; + line-height: 22px; + cursor: pointer; +} + +.primaryButton { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); - font: inherit; - cursor: pointer; } .primaryButton:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } -.secondaryButton { +.secondaryButton, +.addButton { border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 6px 14px; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - font: inherit; - cursor: pointer; + background: transparent; + color: var(--dsw-alias-label-primary); +} + +.secondaryButton:hover:not(:disabled), +.addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); } .secondaryButton:hover:not(:disabled) { @@ -90,12 +126,19 @@ } .dangerButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; border: none; - border-radius: 8px; - padding: 4px 8px; - background: none; + border-radius: 18px; + background: transparent; color: var(--dsw-alias-state-error-primary); font: inherit; + font-size: 14px; + line-height: 22px; cursor: pointer; } @@ -103,17 +146,43 @@ 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 { + opacity: 0.4; cursor: default; } +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.dangerButton:focus-visible, +.addButton:focus-visible, +.linkButton:focus-visible, +.addModelButton:focus-visible, +.iconButton: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(--dsw-alias-border-l2); border-radius: 12px; - background: var(--dsw-alias-bg-layer-2); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -128,11 +197,14 @@ .editorTitle { font-size: 14px; - font-weight: 600; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } .editorRoute { font-size: 12px; + line-height: 18px; color: var(--dsw-alias-label-tertiary); } @@ -147,33 +219,36 @@ align-items: center; gap: 10px; font-size: 12px; + line-height: 18px; font-weight: 500; 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; + 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:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } -.linkButton:disabled { - opacity: 0.5; - cursor: default; -} - .advancedHint { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-label-tertiary); } @@ -194,29 +269,12 @@ align-items: center; gap: 6px; align-self: flex-start; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - cursor: pointer; -} - -.addButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-solid); -} - -.addButton:disabled { - opacity: 0.5; - cursor: default; } .addCard, .setupCard { - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--dsw-alias-bg-layer-3); + background: var(--dsw-alias-bg-module-platform); padding: 14px 16px; display: flex; flex-direction: column; @@ -224,9 +282,9 @@ list-style: none; } +/* Nested in a card that already carries the module chrome. */ .addCard .editor, .setupCard .editor { - border: none; background: none; padding: 0; } @@ -236,12 +294,44 @@ 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(--dsw-alias-label-secondary); - list-style: revert; + 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 { @@ -251,17 +341,173 @@ 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); +} + +.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; +} + +/* 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: 8px; +} + +.modelListHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.modelEntry { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 6px; +} + +.modelRow { + display: grid; + 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; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.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); + 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; + width: 100%; + height: 32px; + padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 10px; + border-radius: 8px; font: inherit; - font-size: 13px; + 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(--dsw-alias-brand-primary); @@ -271,6 +517,11 @@ color: var(--dsw-alias-label-dimmed); } +.input:disabled { + opacity: 0.6; + cursor: default; +} + /* Select variant of .input: replaces the OS arrow (which sits flush against the right edge) with the shared 12px chevron inset like the composer's .select chips; the right pad reserves its cell. */ @@ -288,6 +539,7 @@ .error { margin: 0; font-size: 12px; + line-height: 18px; color: var(--dsw-alias-state-error-primary); } @@ -303,3 +555,20 @@ .deleteConfirm:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); } + +/* 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/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 1aaf9618e2..0f89f329c0 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) @@ -229,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const keyLocked = keyState?.writable === false + /** + * The catalog beneath the user layer: what the composition entry pinned, or + * else the schema default that `resolve` would supply. The effective value + * cannot answer this — it still carries the stored override until the unset + * is applied, so reading it would echo that override straight back the + * moment reset drops it, leaving the rows unchanged until a reload. + */ + const inheritedModels = (): unknown => { + const pinned = getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + } + /** * The curated fields of one known adapter family. Taking the narrowed * family as a parameter is what makes `EFFORT_FIELD` total here: an @@ -236,6 +258,11 @@ 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 : inheritedModels()) + const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = getPath(fallback, ['maxTokens']) return ( <>
@@ -289,6 +316,22 @@ 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 c9491273bc..bb1254e46b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -30,6 +30,27 @@ 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', + 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.', + modelIdRequired: 'Model ID is required.', + 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.', @@ -70,6 +91,27 @@ export const zh: typeof en = { baseUrlDefault: '提供方默认', effort: '推理强度', effortInherit: '默认', + models: '模型目录', + modelsInherited: '正在使用适配器默认模型', + modelsCustomized: '已自定义模型目录', + resetModels: '恢复默认模型', + model: '模型', + modelId: '模型 ID', + modelName: '显示名称', + modelNamePlaceholder: '留空时使用模型 ID', + contextWindow: '上下文窗口', + contextWindowPlaceholder: '使用提供方默认值', + maxTokens: '最大输出 token 数', + maxTokensPlaceholder: '使用提供方默认值', + modelAdvanced: '容量', + addModel: '添加模型', + removeModel: '删除模型', + modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + modelIdRequired: '模型 ID 不能为空。', + 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 8eb2242a08..6c053bba59 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, 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' import { en } from '../src/client/locales.ts' @@ -16,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({ @@ -32,15 +45,54 @@ 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), + // The adapter declares its catalog as a schema default rather than a + // composition entry, which is what the restore-defaults path has to read. + })).default([ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '', + contextWindow: 1_000_000, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '', + contextWindow: 1_000_000, + }, + ]), }) +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, + maxTokens: 256_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 }], @@ -244,6 +296,388 @@ 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)) + expandRow(3) + fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) + fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) + // 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) }) + 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() + 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(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(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(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(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(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(formatCapacity(Number.NaN)).toBe('NaN') + expect(formatCapacity(0)).toBe('0') + for (const text of ['1M', '256K', '131072', '1500K']) { + expect(formatCapacity(parseCapacity(text) as number)).toBe(text) + } + }) + + it('accepts a suffixed context window and stores the plain count', async () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) + // The inherited 1000000 reads back short. + expect((windows[0] as HTMLInputElement).value).toBe('1M') + + // Keystrokes stay verbatim while the row has focus, so typing `1000` does + // not rewrite itself to `1K` mid-word. + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } }) + expect((windows[0] as HTMLInputElement).value).toBe('1000') + fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } }) + expect((windows[0] as HTMLInputElement).value).toBe('1000K') + // Blur settles the row to the canonical spelling of the same count. + fireEvent.blur(windows[0] as HTMLInputElement) + expect((windows[0] as HTMLInputElement).value).toBe('1M') + + fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } }) + fireEvent.blur(windows[1] as HTMLInputElement) + 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[0], contextWindow: 1_000_000 }, + { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 }, + ], + }], + expectedRevision: 0, + }) + }) + + it('keeps unreadable context-window text on screen and refuses the write', async () => { + const { mutate } = await mountSection() + fireEvent.click(screen.getByText(en.customized)) + 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) + fireEvent.blur(windows[0] as HTMLInputElement) + // The text the user typed is still there to correct. + expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion') + + fireEvent.click(screen.getByText(en.apply)) + await screen.findByText(`Model 1: ${en.modelContextInvalid}`) + expect(mutate).not.toHaveBeenCalled() + }) + + it.each([ + ['the schema default', undefined], + ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }], + ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => { + // The regression: reset read the EFFECTIVE value, which still carries the + // stored override until the unset is applied — so the rows did not change + // and the catalog only looked restored after reopening the card. + const { face } = scriptedFace() + const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } + const overridden: SettingsNamespaceView = { + ns: 'llm-deepseek', + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + value: { ...stored, defaultContextWindow: 1_000_000 }, + ...base === undefined ? {} : { base }, + user: stored, + applies: 'live', + secrets: [], + revision: 0, + } + const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx') + render( {}} + />) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsCustomized)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['user-only-model']) + + fireEvent.click(screen.getByText(en.resetModels)) + + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .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)) + 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' } }) + + 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[] => capacityInputs(en.contextWindow) + const removeRow = (at: number): void => { + 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' } }) + 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)) + 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)) + + // 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 + // 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('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)) + 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() + 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 () => { + const { mutate } = await mountSection({ + mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + }) + fireEvent.click(screen.getByText(en.customized)) + 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)) + 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)) + + 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. diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 478046454b..879d9812a2 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -3,11 +3,36 @@ 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 { + 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', () => { + 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('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*:/) }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 84cf173171..637cadc9cc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1771,7 +1771,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', @@ -2317,6 +2317,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/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 9190bd6af8..94dfd3bf93 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: 0b8761f57556d0076f45e2d5ab735acd092cb253 -README.zh.md: fa14741f79450382d178e9f62fb2cfd4b2c4a4a2 +README.md: 552c431e9b82d64d7715e80dc539bcdf2e5c6f49 +README.zh.md: 62086281b50373ac49a59fe9fbb380543d64e660 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0b8761f575..552c431e9b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,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 conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and 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 fa14741f79..62086281b5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,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/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4c8a8138c1..a45688281c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -133,30 +133,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 @@ -177,12 +166,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 }, } })) @@ -1630,7 +1613,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 f19ce27eab..9f9c4329e6 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -165,7 +165,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 9abdd8008c..18315eef19 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 63e53310f7..b65861c1ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -204,7 +204,6 @@ describe('sessions domain schemas', () => { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'fast', - unlisted: true, reasoning: { efforts: [ { id: 'off', name: 'Off' }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 5cf8cda8f6..080342a913 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: 0b2c9efd5ec9bc08e21be1966e182a703c5ea405 +README.md: 72b04f5982ec7fdef024835ab23e7a9f0005f84c +README.zh.md: 2c9155e0da1373e5d9f8a913c10b11ddee417b1f 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 0b2c9efd5e..2c9155e0da 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 9e47a061c4..9d104ace08 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -792,6 +792,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) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index b3478e0e32..2d56f5b4b0 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 0e890bc8bc..f01ee72b0d 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 fa3cb6727e..cd1ac8f3b7 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 7a99983a42..fb87f5d1bf 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 5cff872403..cb9aeeec8b 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: c85fd7e36b8575709ce9341f337da3a8bafd25b0 +README.md: f7dd5619fa69ae226def78415a91d51ba99e0a65 +README.zh.md: c8abe05408ac8917b2c5dc39acdbdf9d9539744d diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 923d983be8..f7dd5619fa 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. 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 c85fd7e36b..c8abe05408 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -6,9 +6,9 @@ 包根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。 -不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.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 退出,也仍按子进程结果处理。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 98dc86d23e..64e92d9bf2 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. */ @@ -20,17 +25,18 @@ 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}. Consumers classify spawn rejection; only + * attributable `ENOENT` or `EACCES` with runner argv[0] provenance becomes an + * 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. */ @@ -142,15 +148,22 @@ 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 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. + * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ -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 /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless @@ -187,8 +200,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 @@ -204,33 +217,25 @@ 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 { if (this.runnerCommand !== undefined) { - const argv0 = this.runnerCommand[0] as string return { 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`, - ], + runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }], } } const selected = this.selectRunner(policy.mode) + const runnerArgv = this.runnerArgv(selected.runner, policy) 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: 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..74d4c2a8a1 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,6 +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 } from 'node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { @@ -35,7 +36,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 } @@ -111,16 +112,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. - runnerFailureSignatures: [ - 'fake-runner: profile rejected', - 'exec: fake-runner: not found', - 'fake-runner: No such file or directory', - 'fake-runner: Permission denied', - ], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: profile rejected'] }], }) expect(probeBwrap).not.toHaveBeenCalled() expect(probeLandlock).not.toHaveBeenCalled() @@ -146,11 +138,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', () => { @@ -163,7 +158,7 @@ describe('the platform chains', () => { argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['read-only file system'], - runnerFailureSignatures: ['bwrap: '], + runnerFailureRules: [{ fatalSignatures: ['bwrap: '] }], }) expect(probeLandlock).not.toHaveBeenCalled() }) @@ -178,14 +173,18 @@ describe('the platform chains', () => { argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['permission denied'], - runnerFailureSignatures: ['landlock-run: '], + runnerFailureRules: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + 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 +193,7 @@ describe('the platform chains', () => { argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) expect(probeSeatbelt).not.toHaveBeenCalled() }) @@ -311,7 +310,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 })) }) @@ -360,7 +359,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }], }) }) diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 2bd8f544f1..11f2a2ed86 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: dff077779153f15aa54ccc24ca1e8551ce3b75df +README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 +README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa 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 dff0777791..adccd4421a 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)的接口包:只依赖 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 c5a0ab9023..f697eb4606 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -673,6 +673,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 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2a6d356d24..9dd7a6bcc9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -864,6 +864,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",