Merge commit '851f05c4f8ae58415224ce1b562962b0c39621ac' into codex/product-subagent-presets

# Conflicts:
#	packages/bundle/base/tests/base.spec.ts
This commit is contained in:
pku-xht
2026-08-10 13:05:13 +08:00
133 changed files with 7867 additions and 395 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md
2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6
2026-08-01-pwsh-tool-and-executor.zh.md: c59ba8e3e6c68d48d310861325c74dc6cec8e5c3
2026-08-01-pwsh-tool-and-executor.md: 855d8e5db8a78e7e798061724d89fde31b28e975
2026-08-01-pwsh-tool-and-executor.zh.md: dcf5c53cd6681be07adafef92d556b400c116736

View File

@@ -6,30 +6,30 @@ English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md)
## Problem
The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry.
The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool is also larger than a Windows-first profile strictly needs — the persistent-PTY twin in particular is bash-shaped surface the `pwsh` tool still does not carry. The original minimal profile also left out background tasks and sandbox escalation: background arrived with the [parity decision](2026-08-02-pwsh-tool-bash-parity.md), and the sandbox surface (denial rendering plus `sandbox_permissions` escalation) arrived with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the minimal tool was sized for the danger-full-access Windows posture, and that premise ended when the sandbox PR re-enabled confinement and approval on Windows.
## Decision
Two new packages under `packages/bash/`:
- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH.
- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description.
- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus the Windows-specific ConstrainedLanguage and named-pipe contracts in the tool description. The parity decision supersedes this note's minimal-profile tool description.
Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines.
The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md).
The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md).
## Alternatives considered
**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation.
**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest.
**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the model-visible contract is the dialect itself (paths, variables, exit facts differ), so a dialect parameter would either churn the schema conditionally or force one tool to teach two dialects; the separate twin keeps the model contract honest — and carries the shared surfaces (background, sandbox, rendering) by mirroring rather than by sharing an implementation.
**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default.
## Consequences
- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims.
- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely.
- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage and named-pipe boundaries precisely.
- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize.
- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal.

View File

@@ -6,30 +6,30 @@ Status: implemented
## 问题
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX硬编码 `bash`,进程组语义是 POSIX 的。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生是 bash 形状表面,最小化的 `pwsh` 工具不该背负
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX硬编码 `bash`,进程组语义是 POSIX 的。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具大于 Windows 优先画像的严格所需——尤其持久 PTY 孪生是 `pwsh` 工具至今仍不背负的 bash 形状表面。最初的最小画像也没有后台任务与沙箱升级:后台随 [parity 决策](2026-08-02-pwsh-tool-bash-parity.md) 到来,沙箱面(拒绝渲染加 `sandbox_permissions` 升级)随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 到来——最小工具当初按 danger-full-access 的 Windows 姿态裁剪,这一前提在 sandbox PRPull Request于 Windows 上重新启用隔离与审批时终结
## 决策
`packages/bash/` 下新增两个包:
- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local``resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker。parity 决策取代了本 note 的最小画像工具描述。
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境bash 的 marker/截断渲染故事(干净退出不产生 marker,以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 与 named-pipe 约定。parity 决策取代了本 note 的最小画像工具描述。
Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行GitHub 托管 runner 预装或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。
## 备选方案
**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell在一个包内塞第二种方言会翻倍配置面`shell` 开关与测试矩阵且两种方言的怪癖Windows 上的信号实情、引号域)应各自归入自己包的文档。
**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动,要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型约定保持诚实
**给 `dsh-tool-bash` 增加方言参数。** 否决:模型可见约定本身就是方言(路径、变量、退出事实都不同),因此方言参数要么让 schema 按条件翻动,要么逼一个工具教两种方言;独立的孪生让模型约定保持诚实——并以镜像而非共享实现的方式携带共享表面(后台、沙箱、渲染)
**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
## 后果
- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范约定一致,因此 `tool-pwsh` 之外的面向模型消费方hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台后台工作(减 sandbox上与 bash 工具行为可互换,提示词指导精确陈述 marker 约定
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 约定、沙箱拒绝/升级词汇,以及 ConstrainedLanguage 与 named-pipe 边界
- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIXPowerShell 输出 CRLF测试做归一化。
- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md
2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49
2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4

View File

@@ -0,0 +1,44 @@
# Agent Note: Windows defaults to pwsh
Status: implemented
English | [中文](2026-08-01-windows-pwsh-default.zh.md)
## Problem
The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell.
## Decision
Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged.
- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce.
- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud.
- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style.
The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior.
## Alternatives considered
**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate.
**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor.
**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access.
**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions.
## Consequences
- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled).
- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass.
- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows.
- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
## Verification
- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service).
- Keyless: a win32 `dsh --profile <name> --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged.
- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes).

View File

@@ -0,0 +1,44 @@
# Agent Note: Windows 默认改用 pwsh
Status: implemented
[English](2026-08-01-windows-pwsh-default.md) | 中文
## 问题
harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片WSL 或 Git-Bash或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。
## 决策
启动交付 profile`dsh web``dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈POSIX 主机不变。
- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runnerlandlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox``sandbox-policy``fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access`permission`/`ui-permission` 离开清单dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。
- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts``win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间覆盖所有组合路径启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。
- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli``dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh``dsh-base` 还声明 `dsh-fs-local`按仓库惯例base bundle 把每个行插件都列为依赖。
pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。
## 备选方案
**在 `dsh-bash-local` 内部让 Windows 默认 pwsh一个执行器方言开关** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell而按平台门控的组合是部署选择不是执行器配置。
**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决patch 应放在它替换的行旁边、属于拥有这些行的 bundle让交付清单作为组合数据保持可见、转储带有出处启动器只贡献 win32 门控。
**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。
**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。
**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。
## 后果
- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。
- Windows 上没有任何沙箱fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。
- POSIX 主机不变平台层永不生效bash 栈仍是通用 `cordis.patch.yml` 的行。
- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud——组合配置是唯一的覆盖通道。
## 验证
- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。
- Keylesswin32 上的 `dsh --profile <name> --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行POSIX 转储CI Linux不变。
- 真实组合冒烟在 win32 上启动 web profilepwsh 栈挂载成功(即本笔记描述的确切清单)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md
2026-08-02-pwsh-tool-bash-parity.md: d61dd6f21223121973520014c44e2e5846e7387a
2026-08-02-pwsh-tool-bash-parity.zh.md: 120f66b1d9c713d5e3b71575186fc2194b0718a5
2026-08-02-pwsh-tool-bash-parity.md: 79a09cb4c9698660faff18cf9ae016bec0bce227
2026-08-02-pwsh-tool-bash-parity.zh.md: 3e01fd4447fbf047b7cbb3949e708094a6c06c09

View File

@@ -10,13 +10,13 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi
## Decision
`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior:
`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, and its model-visible text describes exactly that behavior:
- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts.
- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it.
- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool.
- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker.
- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision.
- **Out of scope, unchanged**: persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). Sandbox escalation shipped later with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the pwsh tool now carries the sandbox denial rendering and the same-turn `sandbox_permissions` escalation surface, plus the Windows ConstrainedLanguage contract in its description. The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision.
## Alternatives considered
@@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi
## Consequences
- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer.
- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes.
- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture.
- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do).
- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before.

View File

@@ -10,13 +10,13 @@ Status: implemented
## 决策
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`减去 sandbox 面,其模型可见文本精确描述这一行为:
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,其模型可见文本精确描述这一行为:
- **渲染完全采用 bash 故事**stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。
- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。
- **`DSH_*` 环境共享而非复制**`BashEnvRegistry``dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env``ctx.bashEnv` + 内置事实 + session-persistence contributor两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。
- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。
- **范围外,不变**sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell后端仅限 Linux/macOSConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
- **范围外,不变**:持久 PTY shell后端仅限 Linux/macOSConPTY 属路线图)。sandbox 升级随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 稍后交付——pwsh 工具现在携带 sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面,外加其描述中的 Windows ConstrainedLanguage 契约。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
## 备选方案
@@ -28,7 +28,7 @@ Status: implemented
## 后果
- bash 与 pwsh 工具在前台后台 shell 工作(减 sandbox上行为可互换pwsh 的 prompt/描述句每句都有渲染器背书。
- bash 与 pwsh 工具在前台后台与沙箱化 shell 工作上行为可互换sandbox 面随 Windows ACL sandbox 决策到来pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过
- 对齐也反向发生过一次pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。
- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash``dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`spine bundle 已如此)。
- Windows 专属语义CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md
2026-08-05-pwsh-ui-bash-parity.md: ba92f482e957acd00792a2a8053716047a4da27d
2026-08-05-pwsh-ui-bash-parity.zh.md: 693b3fc26e632718ea2135282d63daa32e63e1c5
2026-08-05-pwsh-ui-bash-parity.md: a59ae95ab64ae28babc913958a1a6ba424898210
2026-08-05-pwsh-ui-bash-parity.zh.md: 8e065640e56bf6d6b92e62cb746fa9dd53f6f2b5

View File

@@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md)
## Problem
The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects.
The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects.
## Decision

View File

@@ -6,7 +6,7 @@ Status: implemented
## Problem
[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。
[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。
## Decision

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md
2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76
2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf

View File

@@ -0,0 +1,33 @@
# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter
Status: implemented
English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md)
## Problem
While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything".
## Decision
Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary.
The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter.
The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window).
## Consequences
One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative.
## Related
The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision.
## Alternatives considered
- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op.
- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence.
- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO.
- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing.
- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly.

View File

@@ -0,0 +1,33 @@
# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话
Status: implemented
[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文
## Problem
主会话运行时,用户用普通 Enter或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。
## Decision
空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()``SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。
该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer草稿内容优先于队列加速 Enter 只插话当前草稿idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。
同一套计算得出的可用性门控也负责提示该手势当草稿为空、输入框未锁定且不处于瞬态机器锁adjudicating/submitting、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder该窗口内手势确实可用
## Consequences
一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控hub 在执行时会重新读取快照因此客户端门控仍只是建议性的host 仍是权威。
## Related
逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。
## Alternatives considered
- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。
- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。
- **并发触发所有行。** 已拒绝host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。
- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer中途关闭静默收敛——协议改动没有收益。
- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525

View File

@@ -0,0 +1,43 @@
# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer
Status: implemented
English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)
## Problem
The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading.
## Decision
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack.
## How the restriction works (why no new identity)
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
## Alternatives considered
### Why not mxc (Microsoft xContainer)?
Two disqualifiers. First, the OS floor is too new: the [mxc OS-version policy](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, `Experimental_CreateProcessInSandbox`) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need.
### Why not AppContainer?
An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only.
### Why not landstrip?
The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem.
## Consequences
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description.
## Testing
The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite).
## Related
The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes.

View File

@@ -0,0 +1,43 @@
# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer
Status: implemented
[English](2026-08-08-windows-acl-restricted-token-sandbox.md) | 中文
## Problem
[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。
## Decision
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken``WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closedPOC 因忽略返回值而 fail-open。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录sha256、16 位 hex——任何地方都不存储因此不存在篡改面并独占创建它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾其下一次恢复会在独占创建处大声失败直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose资源释放时撤销因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SIDread-only = [登录 SID、Everyone]workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone没有它们早期 DLL init 会以 0xC0000142 死亡CNG 会让 pwsh 以 0xE0434352 崩溃。Read-only 不含写入 SID先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败0x80041003因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE在两种模式下都被关闭INTERACTIVE/LOCAL 同样在两种列表中都缺席Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住。Workspace-write 子进程看到的是私有的每会话临时子目录(`<temp>\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE`SetTokenInformation(TokenDefaultDacl)`此后不带显式安全描述符创建的新对象匿名管道——CreatePipe、同步对象自带 restricting SID ACE创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付Windows 平台层在受限 pwsh 栈之上重新启用完整权限面sandbox/sandbox-policy/permission/approval/fs-sandbox
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线landstrip 的 restricted-user、AppContainer用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID一次按 restricting SIDs——只有两次都放行写类访问才被授予。读只凭正常检查即可通过调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
## Alternatives considered
### 为什么不选 mxcMicrosoft xContainer
两个否决理由。其一OS 版本要求太新:[mxc 的 OS 版本支持文档](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md)把产品下限设在 Windows 11 24H2build 26100而 BaseContainer 档T1`Experimental_CreateProcessInSandbox`)只在 25H2+build 26600+)且启用 OS feature 时存在——在 25H2 及以下的所有受支持版本上,文件系统策略都会回退到 T3即 AppContainer 加宿主侧 DACL ACE 改造。其二,在任一档下支持任意路径读都意味着要为子进程可读的每个路径写 ACL 授予读权限:模型要读整个工作区和任意文件,就需要全盘改写宿主 DACL——对只做写限制的需求而言这是不必要的驻留副作用与代价。
### 为什么不选 AppContainer
AppContainer 令牌没有环境读访问:每个可读路径都必须预先通过 capability 或显式 ACE 授予因此任意路径读——harness 的读模型——在不做同样的全盘授予时无法支持。受限令牌完全不需要读授予:它只对写访问做交集。
### 为什么不选 landstrip
[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。
## Consequences
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响与模式词汇表一致、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒因按工作区身份每台机器每个工作区只付一次CIM 在**两种**受限模式下都不可用AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL目标在两种模式下仍可写没有可做交集的安全描述符——作为历史残留处理不支持、仅警告、已在 README 中记录NULL DACL 目录在 grant+revoke 往返下不保持身份记录在案的边角POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败`-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。
## Testing
产品可见的 Windows 阵容切换仅存在于 win32而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec[`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/``packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts``--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。
## Related
[pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md
2026-08-01-windows-pwsh-default.md: 64d713aecda60d8747199aff5334a80bf1867d92
2026-08-01-windows-pwsh-default.zh.md: 74ba6c43dea984384a9b7dd2675b681ee025d6ca

View File

@@ -1,39 +0,0 @@
# Agent Note: Windows defaults to pwsh (roadmap)
Status: proposed
English | [中文](2026-08-01-windows-pwsh-default.zh.md)
## Problem
The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them.
## Proposal
Two follow-up stages, each independently shippable. The bash-tool parity twin shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface.
1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end.
2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed.
The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior.
## Alternatives considered
**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible.
**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior.
## Acceptance criteria
- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there.
- POSIX hosts are byte-for-byte unaffected (same roster, same executor).
- The shipped-composition e2es assert the platform-gated roster on both families.
- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot).
## Risks
- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch.
- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed.
- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1.

View File

@@ -1,39 +0,0 @@
# Agent Note: Windows 默认改用 pwsh路线图
Status: proposed
[English](2026-08-01-windows-pwsh-default.md) | 中文
## 问题
harness 交付的执行配置在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片WSL 或 Git-Bash或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言TUI/Web 界面按照 bash 风格的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。
## 提案
两个阶段各自可独立交付。bash 工具对等孪生已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在除 sandbox 接口外,在前台与后台工作方面均与 `tool-bash` 对齐,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装后形态的 keyless 应用快照。
1. **Windows 默认组合**——交付的 CLI命令行界面组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bashPOSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。
2. **pwsh GUI 渲染**——Web 界面使用 bash 风格的终端呈现来渲染 pwsh 调用(带胶囊状退出状态标签的终端卡片),与 bash 终端卡片相对应。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付TUI 已移除,不再有对应的终端界面。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍无人认领。
各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。
## 备选方案
**在 `dsh-bash-local` 内部让 Windows 默认 pwsh一个执行器方言开关** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell而按平台门控的组合是部署选择不是执行器配置。
**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义并且它属于会在审批PTY 界面上显现的组合决策。
**用垫片在 Windows 上保留 bash跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。
## 验收标准
- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`
- POSIX 主机逐字节不受影响(清单相同,执行器相同)。
- 交付组合 e2e 在两个平台族上断言按平台门控的清单。
- 阶段 1 落地时parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地TUI 的移除意味着不再有可供快照测试的终端界面)。
## 风险
- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell 钩子的钩子桥接、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。
- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 暴露出来;这些覆盖必须按阶段扩展,不能想当然地认为已经具备。
- **渲染约定**——与 bash 风格一致的终端呈现已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍是一项需要快照覆盖的 UI 设计决策,随阶段 1 一起延期。

View File

@@ -34,6 +34,8 @@
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
@@ -47,6 +49,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",

View File

@@ -15,6 +15,7 @@ import {
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
label: layer.packageName,
patches: layer.patches,
}))
// The win32 shell platform layer rides between bundles and user layers,
// exactly where the boot applies it.
const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME)
if (windowsShellLayer !== undefined) {
layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches })
}
if (!defaultOnly) {
if (existsSync(loaded.patchPath)) {
layers.push({ label: loaded.patchPath, patches: loaded.patches })

View File

@@ -35,6 +35,7 @@ const USER_PRESET_DIR = '.agent-presets'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -111,6 +112,8 @@ interface ComposedProfile {
profile: Profile
/** Bundle layers concatenated — the part below the user layers on a live reload. */
bundlePatches: PatchOptions[]
/** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
windowsShellPatches: PatchOptions[]
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
homePatches: PatchOptions[]
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
@@ -125,12 +128,19 @@ interface ComposedProfile {
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
return [
...composed.bundlePatches,
...composed.windowsShellPatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlayAndFlags,
]
}
/**
* Load `name` and compose its effective patch stack: bundle layers in
* `dsh.profile.bundles` order, the profile's user layer, the home-level user layer
* `dsh.profile.bundles` order, the win32 shell platform layer (when the host
* is Windows), the profile's user layer, the home-level user layer
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
* every profile, so it outranks the per-profile layer), `--patch` overlays,
* then flag patches derived from the composed rows, then the telemetry
@@ -149,8 +159,9 @@ function composeProfile(
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
const rows = new Map<string, { name?: string; config?: unknown }>()
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
@@ -174,7 +185,7 @@ function composeProfile(
}
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows }
}
/** Options for {@link runProfile}. */
@@ -253,6 +264,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// removing the override could never revert the row to the bundle default.
const composeLive = (): PatchOptions[] => structuredClone([
...composed.bundlePatches,
...composed.windowsShellPatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlayAndFlags,

View File

@@ -0,0 +1,52 @@
/**
* The Windows shell platform layer: on win32 hosts the shipped profile
* compositions swap the POSIX-only bash stack for the sandbox-confined
* PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` +
* `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's
* `windows.cordis.patch.yml`, injected by the launcher between the bundle
* layers and the user layers so a user patch can still override it — the
* only override channel is composition config, like every other roster
* decision. POSIX hosts never receive the layer.
* @module @deepseek-ai/dsh/windows-shell
*/
import { join } from 'node:path'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
/** The base bundle whose package carries the Windows shell patch. */
export const BASE_BUNDLE = '@deepseek-ai/dsh-base'
/** The Windows shell patch filename inside the base bundle package. */
export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml'
/** One Windows shell platform layer: its patch file and parsed patches. */
export interface WindowsShellLayer {
/** The patch file path, used as the config-dump provenance label. */
label: string
/** The parsed patch entries, applied after the bundle layers. */
patches: PatchOptions[]
}
/**
* Resolve the Windows shell platform layer for a profile composition.
* @param platform - the host platform (`process.platform` at call sites).
* @param layers - the profile's bundle layers, in application order.
* @param binName - the diagnostic prefix on thrown errors (`dsh`).
* @returns the pwsh layer on win32, else `undefined`. A custom profile that
* mounts no base bundle is skipped (it owns its shell stack); a base
* bundle whose Windows shell patch is missing fails loud in
* {@link loadOverlayPatches} — the shipped package always carries it, so
* a miss is a broken installation.
*/
export function resolveWindowsShellLayer(
platform: NodeJS.Platform,
layers: readonly ProfileLayer[],
binName: string,
): WindowsShellLayer | undefined {
if (platform !== 'win32') return undefined
const base = layers.find(layer => layer.packageName === BASE_BUNDLE)
if (base === undefined) return undefined
const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME)
return { label, patches: loadOverlayPatches(binName, label) }
}

View File

@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
import {
BASE_BUNDLE,
resolveWindowsShellLayer,
WINDOWS_SHELL_PATCH_FILENAME,
} from '../src/windows-shell.ts'
const WINDOWS_PATCH = `- id: bash-sandbox
disabled: true
- insert:
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
`
/** One fake bundle layer rooted in a temp directory. */
function fakeLayer(packageName: string, dir: string): ProfileLayer {
return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
}
/** A base bundle layer whose package carries the Windows shell patch. */
function baseLayerWithPatch(dir: string): ProfileLayer {
writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
return fakeLayer(BASE_BUNDLE, dir)
}
describe('resolveWindowsShellLayer', () => {
let base: string
afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
const tempBase = (): string => {
base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
return base
}
it('never applies on POSIX hosts', () => {
expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
})
it('defaults Windows hosts to the pwsh platform layer', () => {
const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
expect(layer).toBeDefined()
expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
expect(layer?.patches).toEqual([
{ id: 'bash-sandbox', disabled: true },
{ insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
])
})
it('skips custom profiles without a base bundle', () => {
const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
})
it('fails loud when the base bundle ships no Windows shell patch', () => {
const base = tempBase()
mkdirSync(base, { recursive: true })
// The overlay loader owns the fail-loud contract: the caller named this
// file, so its absence is a misconfiguration, not "no overlay".
expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
.toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
})
})
describe('the shipped Windows composition (real bundle layers)', () => {
let home: string
afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
// The app installation anchor, mirroring profile-boot.ts: the bundle layers
// resolve from the REAL dsh-base/dsh-web-app packages through it, so this
// suite composes the shipped patch files, not test fixtures.
const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
it('composes the win32 confined roster through the real patch layers', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
const warnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
expect(win32).toBeDefined()
const rows = composeEntries(
[...profile.layers.map(layer => layer.patches), win32!.patches],
message => warnings.push(message),
)
const byId = new Map(rows.map(row => [row.id, row]))
// Only the POSIX bash stack leaves the roster: the permission surface
// (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
// exactly as on POSIX — the confined pwsh executor is what changes.
for (const id of ['bash-sandbox', 'tool-bash']) {
expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
}
for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
}
for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
expect(byId.has(id), `inserted row ${id}`).toBe(true)
}
// The launcher's cold-start module fallback BFS-links the apps/cli
// dependency closure into the profile's node_modules (the pwsh-local
// precedent), so every inserted bare plugin must resolve from there.
const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
}
// The patch touches only base-owned rows plus inserts, so the full web
// profile composes without any no-match warning.
expect(warnings).toEqual([])
})
it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
// POSIX: no platform layer, the bash stack stays enabled.
const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
const posixById = new Map(posixRows.map(row => [row.id, row]))
expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
expect(posixById.has('pwsh-local')).toBe(false)
expect(posixById.has('pwsh-sandbox')).toBe(false)
// A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
// patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
// inserts, so the composition produces no no-match warning.
initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
const baseWarnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
expect(win32).toBeDefined()
composeEntries(
[...baseOnly.layers.map(layer => layer.patches), win32!.patches],
message => baseWarnings.push(message),
)
expect(baseWarnings).toEqual([])
})
})

View File

@@ -16,7 +16,7 @@
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -32,7 +32,7 @@
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -31,7 +31,7 @@
- button "Clear goal":
- img
- button "2 queued messages"
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -25,7 +25,7 @@
- img
- button "Steer queued message":
- img
- textbox "Message the agent"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write

View File

@@ -0,0 +1,33 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: Running
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -0,0 +1,47 @@
[
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "reasoning" },
{ "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." },
{ "type": "block-start", "index": 1, "blockType": "tool-call" },
{
"type": "tool-call-delta",
"index": 1,
"id": "call_00_steer_all",
"name": "ask_user_question",
"argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "reasoning",
"text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
}
},
{
"type": "block-end",
"index": 1,
"block": {
"type": "tool-call",
"id": "call_00_steer_all",
"name": "ask_user_question",
"arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
}
},
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "text" },
{ "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." },
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } },
{ "type": "finish", "reason": { "kind": "stop" } }
]
}
]

View File

@@ -0,0 +1,43 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- button "Ask question 1/1 answered":
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}"
- button "Copy":
- img
- paragraph: "Got it: BANANA and ORANGE."
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok

View File

@@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
const STEER = 'Interjection: include the word BANANA in your final reply.'
// Empty-draft flush scenario: an override-only fixture. The whole-script
// replacement answers both model calls of a FRESH session (no recorded
// session.jsonl exists — call 0 keeps the turn open with a question-tool
// call, call 1 is the reply after both steerings drain).
const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url))
const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl')
const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json')
const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md')
const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md')
const STEER_ONE = 'Interjection: include the word BANANA in your final reply.'
const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
/** Concatenated assistant text deltas — the model-visible reply body. */
function assistantText(events: SessionEvent[]): string {
return events
@@ -278,3 +290,102 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
expect(tripwire.warnings).toEqual([])
}, 90_000)
})
describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
// The scenario boots a fresh session against the override-only fixture;
// the replay.override.json sidecar replaces the derived script, so the
// (deliberately absent) session.jsonl is never read.
scaffold = await launchWebScaffold({
replayFixture: STEER_ALL_FIXTURE,
replayOverride: STEER_ALL_OVERRIDE,
paceMs: REPLAY_PACE_MS,
})
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all'))
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled(30_000)
// Call 0 streams a question-tool call; the fills must land inside the
// first replay window, before the question composer replaces the textarea.
await input.fill(PROMPT)
await input.press('Enter')
await input.fill(STEER_ONE)
await input.press('Enter')
await input.fill(STEER_TWO)
await input.press('Enter')
const dock = page.locator('[data-queue-dock]')
// Both messages queued: the two-row dock shows a collapsed count header,
// and Playwright text matching skips the hidden rows — expand the list,
// then assert each row's content.
await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 })
await dock.getByRole('button').click()
await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 })
await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 })
expect(await page.locator('[data-pending-steering]').count()).toBe(0)
// Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock
// empties, and the pending steering renders at the conversation tail.
await input.press('Meta+Enter')
await expect.poll(
() => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(),
{ timeout: 10_000 },
).toBe(2)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
// The reasoning row streams independently of the steering handoff; wait
// for it so the mid snapshot pins the assistant step, not the pre-render
// gap a fast machine can catch between steering acceptance and the block.
await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 })
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)
// Answer the question; the step closes, the loop drains both steerings
// into one next-step request, and the final reply obeys both markers.
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
const first = claimedMessages(sessionEvents, STEER_ONE)
const second = claimedMessages(sessionEvents, STEER_TWO)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(assistantText(sessionEvents)).toContain('BANANA')
expect(assistantText(sessionEvents)).toContain('ORANGE')
await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await page.locator('[data-pending-steering]').count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(STEER_ALL_DIR, [
'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md',
])
})
})

View File

@@ -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/config-catalog.md
config-catalog.md: 8950c5bb1a72b5e06954d44f9ea3d8f37ec9f0e1
config-catalog.zh.md: f0a4cedb44290ef1ecef3bff59538596235bcd96
config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4
config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca

View File

@@ -1217,6 +1217,26 @@ export interface Config {
Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts)
## `@deepseek-ai/dsh-pwsh-sandbox`
Requires: `subprocess` · `sandbox` · `sandboxPolicy`
```ts config-catalog
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The
* runner choice is likewise the `ctx.sandbox` provider's config, not this
* executor's.
*/
export type Config = LocalConfig
```
Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local)
Source: [`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog
@@ -1293,7 +1313,7 @@ export interface Config {
}
```
Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts)
Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts)
## `@deepseek-ai/dsh-sandbox-policy`
@@ -2149,7 +2169,7 @@ export interface Config {
}
```
Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts)
Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts)
## `@deepseek-ai/dsh-tool-ralph`
@@ -2757,6 +2777,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-scripts` ([`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts))
- `@deepseek-ai/dsh-sdk-client` ([`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts))

View File

@@ -1219,6 +1219,26 @@ export interface Config {
来源:[`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts)
## `@deepseek-ai/dsh-pwsh-sandbox`
需要:`subprocess` · `sandbox` · `sandboxPolicy`
```ts config-catalog
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The
* runner choice is likewise the `ctx.sandbox` provider's config, not this
* executor's.
*/
export type Config = LocalConfig
```
依赖:[`LocalConfig`](#deepseek-aidsh-pwsh-local)
来源:[`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog
@@ -1295,7 +1315,7 @@ export interface Config {
}
```
来源:[`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts)
来源:[`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts)
## `@deepseek-ai/dsh-sandbox-policy`
@@ -2150,7 +2170,7 @@ export interface Config {
}
```
来源:[`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts)
来源:[`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts)
## `@deepseek-ai/dsh-tool-ralph`
@@ -2757,6 +2777,7 @@ export interface Config {
- `@deepseek-ai/dsh-native-command`[`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)
- `@deepseek-ai/dsh-paths`[`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)
- `@deepseek-ai/dsh-retention`[`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)
- `@deepseek-ai/dsh-sandbox-windows-acl`[`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)
- `@deepseek-ai/dsh-scope`[`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)
- `@deepseek-ai/dsh-scripts`[`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts)
- `@deepseek-ai/dsh-sdk-client`[`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts)

View File

@@ -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/module-graph.md
module-graph.md: e182855f785ba77210d455f7c538596a2eddc784
module-graph.zh.md: b65d1a4b35d0a593291423171ec9cbca611c5f04
module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3
module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa

View File

@@ -45,6 +45,7 @@ flowchart TD
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
pkg_pwsh_local["pwsh-local"]
pkg_pwsh_sandbox["pwsh-sandbox"]
pkg_tool_bash["tool-bash"]
pkg_tool_pwsh["tool-pwsh"]
end
@@ -237,6 +238,7 @@ flowchart TD
pkg_sandbox["sandbox"]
pkg_sandbox_local["sandbox-local"]
pkg_sandbox_policy["sandbox-policy"]
pkg_sandbox_windows_acl["sandbox-windows-acl"]
end
subgraph group_scaffold["packages/scaffold"]
pkg_helper["helper"]
@@ -319,6 +321,7 @@ flowchart TD
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_directory_picker --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_sandbox_windows_acl --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants
pkg_type_meta --> pkg_invariants
@@ -416,8 +419,6 @@ flowchart TD
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_sandbox --> pkg_invariants
pkg_sandbox --> pkg_llm
pkg_settings_local --> pkg_atomic_write
pkg_settings_local --> pkg_invariants
pkg_settings_local --> pkg_paths
@@ -428,13 +429,6 @@ flowchart TD
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
pkg_fs --> pkg_sandbox
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
@@ -498,9 +492,9 @@ flowchart TD
pkg_agent_presets --> pkg_settings
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox_local --> pkg_invariants
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox --> pkg_invariants
pkg_sandbox --> pkg_llm
pkg_sandbox --> pkg_session
pkg_session_persistence --> pkg_brand
pkg_session_persistence --> pkg_invariants
pkg_session_persistence --> pkg_session
@@ -525,22 +519,13 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_type_meta
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
pkg_fs_local --> pkg_invariants
pkg_fs_policy --> pkg_fs
pkg_fs_policy --> pkg_invariants
pkg_skill_local --> pkg_fs
pkg_skill_local --> pkg_invariants
pkg_skill_local --> pkg_paths
pkg_skill_local --> pkg_skill
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
pkg_fs --> pkg_sandbox
pkg_web_search_deepseek --> pkg_agent
pkg_web_search_deepseek --> pkg_credentials
pkg_web_search_deepseek --> pkg_environment
@@ -549,9 +534,6 @@ flowchart TD
pkg_web_search_deepseek --> pkg_web
pkg_spill_local --> pkg_invariants
pkg_spill_local --> pkg_spill
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_loader_smoke --> pkg_agent
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
@@ -563,13 +545,6 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
pkg_fs_e2b --> pkg_e2b
pkg_fs_e2b --> pkg_fs
pkg_fs_e2b --> pkg_invariants
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_browse --> pkg_client_locale
@@ -597,16 +572,13 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_invariants
pkg_user_interaction --> pkg_llm
pkg_lsp_local --> pkg_brand
pkg_lsp_local --> pkg_fs
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
pkg_lsp_local --> pkg_lsp
pkg_lsp_local --> pkg_subprocess
pkg_lsp_local --> pkg_timeout
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
pkg_sandbox_local --> pkg_invariants
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox_local --> pkg_session
pkg_sandbox_policy --> pkg_agent
pkg_sandbox_policy --> pkg_invariants
pkg_sandbox_policy --> pkg_sandbox
@@ -664,21 +636,30 @@ flowchart TD
pkg_goal_session --> pkg_invariants
pkg_goal_session --> pkg_llm
pkg_goal_session --> pkg_session
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_invariants
pkg_bash_sandbox --> pkg_sandbox
pkg_bash_sandbox --> pkg_sandbox_policy
pkg_fs_sandbox --> pkg_fs
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
pkg_fs_local --> pkg_invariants
pkg_fs_policy --> pkg_fs
pkg_fs_policy --> pkg_invariants
pkg_skill_local --> pkg_fs
pkg_skill_local --> pkg_invariants
pkg_skill_local --> pkg_paths
pkg_skill_local --> pkg_skill
pkg_compact --> pkg_brand
pkg_compact --> pkg_commands
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_session_query --> pkg_brand
pkg_session_query --> pkg_invariants
pkg_session_query --> pkg_llm
@@ -705,6 +686,13 @@ flowchart TD
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
pkg_fs_e2b --> pkg_e2b
pkg_fs_e2b --> pkg_fs
pkg_fs_e2b --> pkg_invariants
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
@@ -721,6 +709,13 @@ flowchart TD
pkg_permission --> pkg_session_projection
pkg_permission --> pkg_settings
pkg_permission --> pkg_user_approval
pkg_lsp_local --> pkg_brand
pkg_lsp_local --> pkg_fs
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
pkg_lsp_local --> pkg_lsp
pkg_lsp_local --> pkg_subprocess
pkg_lsp_local --> pkg_timeout
pkg_pty_local --> pkg_agent
pkg_pty_local --> pkg_invariants
pkg_pty_local --> pkg_pty
@@ -764,6 +759,21 @@ flowchart TD
pkg_bash_env --> pkg_paths
pkg_bash_env --> pkg_session_persistence
pkg_bash_env --> pkg_tools
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_invariants
pkg_bash_sandbox --> pkg_sandbox
pkg_bash_sandbox --> pkg_sandbox_policy
pkg_pwsh_sandbox --> pkg_bash
pkg_pwsh_sandbox --> pkg_invariants
pkg_pwsh_sandbox --> pkg_pwsh_local
pkg_pwsh_sandbox --> pkg_sandbox
pkg_pwsh_sandbox --> pkg_sandbox_policy
pkg_fs_sandbox --> pkg_fs
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_tool_fs --> pkg_fs
pkg_tool_fs --> pkg_invariants
pkg_tool_fs --> pkg_llm
@@ -962,9 +972,12 @@ flowchart TD
pkg_tool_pwsh --> pkg_bash_env
pkg_tool_pwsh --> pkg_invariants
pkg_tool_pwsh --> pkg_llm
pkg_tool_pwsh --> pkg_sandbox
pkg_tool_pwsh --> pkg_sandbox_policy
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tasks
pkg_tool_pwsh --> pkg_tools
pkg_tool_pwsh --> pkg_user_approval
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
@@ -1233,6 +1246,7 @@ flowchart TD
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
@@ -1266,11 +1280,8 @@ flowchart TD
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
@@ -1287,33 +1298,27 @@ flowchart TD
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1327,17 +1332,24 @@ flowchart TD
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
@@ -1346,6 +1358,9 @@ flowchart TD
| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
@@ -1378,7 +1393,7 @@ flowchart TD
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |

View File

@@ -47,6 +47,7 @@ flowchart TD
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
pkg_pwsh_local["pwsh-local"]
pkg_pwsh_sandbox["pwsh-sandbox"]
pkg_tool_bash["tool-bash"]
pkg_tool_pwsh["tool-pwsh"]
end
@@ -239,6 +240,7 @@ flowchart TD
pkg_sandbox["sandbox"]
pkg_sandbox_local["sandbox-local"]
pkg_sandbox_policy["sandbox-policy"]
pkg_sandbox_windows_acl["sandbox-windows-acl"]
end
subgraph group_scaffold["packages/scaffold"]
pkg_helper["helper"]
@@ -321,6 +323,7 @@ flowchart TD
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_directory_picker --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_sandbox_windows_acl --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants
pkg_type_meta --> pkg_invariants
@@ -418,8 +421,6 @@ flowchart TD
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_sandbox --> pkg_invariants
pkg_sandbox --> pkg_llm
pkg_settings_local --> pkg_atomic_write
pkg_settings_local --> pkg_invariants
pkg_settings_local --> pkg_paths
@@ -430,13 +431,6 @@ flowchart TD
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
pkg_fs --> pkg_sandbox
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
@@ -500,9 +494,9 @@ flowchart TD
pkg_agent_presets --> pkg_settings
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox_local --> pkg_invariants
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox --> pkg_invariants
pkg_sandbox --> pkg_llm
pkg_sandbox --> pkg_session
pkg_session_persistence --> pkg_brand
pkg_session_persistence --> pkg_invariants
pkg_session_persistence --> pkg_session
@@ -527,22 +521,13 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_type_meta
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
pkg_fs_local --> pkg_invariants
pkg_fs_policy --> pkg_fs
pkg_fs_policy --> pkg_invariants
pkg_skill_local --> pkg_fs
pkg_skill_local --> pkg_invariants
pkg_skill_local --> pkg_paths
pkg_skill_local --> pkg_skill
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
pkg_fs --> pkg_sandbox
pkg_web_search_deepseek --> pkg_agent
pkg_web_search_deepseek --> pkg_credentials
pkg_web_search_deepseek --> pkg_environment
@@ -551,9 +536,6 @@ flowchart TD
pkg_web_search_deepseek --> pkg_web
pkg_spill_local --> pkg_invariants
pkg_spill_local --> pkg_spill
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_loader_smoke --> pkg_agent
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
@@ -565,13 +547,6 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
pkg_fs_e2b --> pkg_e2b
pkg_fs_e2b --> pkg_fs
pkg_fs_e2b --> pkg_invariants
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_browse --> pkg_client_locale
@@ -599,16 +574,13 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_invariants
pkg_user_interaction --> pkg_llm
pkg_lsp_local --> pkg_brand
pkg_lsp_local --> pkg_fs
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
pkg_lsp_local --> pkg_lsp
pkg_lsp_local --> pkg_subprocess
pkg_lsp_local --> pkg_timeout
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
pkg_sandbox_local --> pkg_invariants
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox_local --> pkg_session
pkg_sandbox_policy --> pkg_agent
pkg_sandbox_policy --> pkg_invariants
pkg_sandbox_policy --> pkg_sandbox
@@ -666,21 +638,30 @@ flowchart TD
pkg_goal_session --> pkg_invariants
pkg_goal_session --> pkg_llm
pkg_goal_session --> pkg_session
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_invariants
pkg_bash_sandbox --> pkg_sandbox
pkg_bash_sandbox --> pkg_sandbox_policy
pkg_fs_sandbox --> pkg_fs
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
pkg_fs_local --> pkg_invariants
pkg_fs_policy --> pkg_fs
pkg_fs_policy --> pkg_invariants
pkg_skill_local --> pkg_fs
pkg_skill_local --> pkg_invariants
pkg_skill_local --> pkg_paths
pkg_skill_local --> pkg_skill
pkg_compact --> pkg_brand
pkg_compact --> pkg_commands
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_session_query --> pkg_brand
pkg_session_query --> pkg_invariants
pkg_session_query --> pkg_llm
@@ -707,6 +688,13 @@ flowchart TD
pkg_client_test_runtime --> pkg_client_web_react
pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
pkg_fs_e2b --> pkg_e2b
pkg_fs_e2b --> pkg_fs
pkg_fs_e2b --> pkg_invariants
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
@@ -723,6 +711,13 @@ flowchart TD
pkg_permission --> pkg_session_projection
pkg_permission --> pkg_settings
pkg_permission --> pkg_user_approval
pkg_lsp_local --> pkg_brand
pkg_lsp_local --> pkg_fs
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
pkg_lsp_local --> pkg_lsp
pkg_lsp_local --> pkg_subprocess
pkg_lsp_local --> pkg_timeout
pkg_pty_local --> pkg_agent
pkg_pty_local --> pkg_invariants
pkg_pty_local --> pkg_pty
@@ -766,6 +761,21 @@ flowchart TD
pkg_bash_env --> pkg_paths
pkg_bash_env --> pkg_session_persistence
pkg_bash_env --> pkg_tools
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_invariants
pkg_bash_sandbox --> pkg_sandbox
pkg_bash_sandbox --> pkg_sandbox_policy
pkg_pwsh_sandbox --> pkg_bash
pkg_pwsh_sandbox --> pkg_invariants
pkg_pwsh_sandbox --> pkg_pwsh_local
pkg_pwsh_sandbox --> pkg_sandbox
pkg_pwsh_sandbox --> pkg_sandbox_policy
pkg_fs_sandbox --> pkg_fs
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_tool_fs --> pkg_fs
pkg_tool_fs --> pkg_invariants
pkg_tool_fs --> pkg_llm
@@ -964,9 +974,12 @@ flowchart TD
pkg_tool_pwsh --> pkg_bash_env
pkg_tool_pwsh --> pkg_invariants
pkg_tool_pwsh --> pkg_llm
pkg_tool_pwsh --> pkg_sandbox
pkg_tool_pwsh --> pkg_sandbox_policy
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tasks
pkg_tool_pwsh --> pkg_tools
pkg_tool_pwsh --> pkg_user_approval
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
@@ -1235,6 +1248,7 @@ flowchart TD
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
@@ -1268,11 +1282,8 @@ flowchart TD
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
@@ -1289,33 +1300,27 @@ flowchart TD
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1329,17 +1334,24 @@ flowchart TD
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
@@ -1348,6 +1360,9 @@ flowchart TD
| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
@@ -1380,7 +1395,7 @@ flowchart TD
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |

View File

@@ -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/subsystems/sandbox.md
sandbox.md: dd960b3021dcdc87cfd36fd439cbec0a810dd736
sandbox.zh.md: 23644bb43a131a0e3c8595187a6fc11e74682d9e
sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46
sandbox.zh.md: 5f5465af46aa88d72b4a39f728f18855156b24ba

View File

@@ -8,7 +8,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox
## Modes and enforcement
`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
```ts type-equiv
/**
@@ -53,6 +53,14 @@ interface SandboxExecutionPolicy {
mode: SandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
/**
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. the windows-acl
* per-session private temp subdirectory — the write grant itself is
* per-workspace, derived from the workspace root); absent for agentless
* calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
}
```
@@ -176,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
```
Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts)
Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts)
<a id="ctxsandboxpolicy--sandboxpolicyservice"></a>

View File

@@ -8,7 +8,7 @@
## 模式与强制执行
`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外)`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。
`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。
```ts type-equiv
/**
@@ -53,6 +53,14 @@ interface SandboxExecutionPolicy {
mode: SandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
/**
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. the windows-acl
* per-session private temp subdirectory — the write grant itself is
* per-workspace, derived from the workspace root); absent for agentless
* calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
}
```
@@ -176,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
```
Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts)
Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts)
<a id="ctxsandboxpolicy--sandboxpolicyservice"></a>

View File

@@ -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/tool-catalog.md
tool-catalog.md: 58267e208d919e7fa817991207a44ac0864fa00e
tool-catalog.zh.md: 1eda3506644bae6da56895c366a56b566536ed62
tool-catalog.md: dbab9ce2f389dbfe40e7d753ced995a8a384be17
tool-catalog.zh.md: e99f8bc78923e616265427c1e0361c832cc0930f

View File

@@ -212,7 +212,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.
```json
{

View File

@@ -214,7 +214,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行调用之间不保留任何状态cwd、变量、函数请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 task id使用 `task_output` 读取输出,使用 `task_kill` 停止任务。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行调用之间不保留任何状态cwd、变量、函数请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 task id使用 `task_output` 读取输出,使用 `task_kill` 停止任务。
```json
{

View File

@@ -15,7 +15,7 @@
{"type":"assistant/chunk","seq":13,"time":1785916902468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":14,"time":1785916902468,"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":"d588acd6-d0ab-43c5-9e18-67fe3f625e48"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1785916902469,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}
{"type":"tool/result","seq":16,"time":1785916902487,"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":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"tool/result","seq":16,"time":1785916902487,"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), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":1785916902487,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":18,"time":1785916902496,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":19,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}

View File

@@ -5,10 +5,12 @@
],
"ignoreBinaries": [
"bwrap",
"icacls",
"musl-gcc",
"python3",
"sandbox-exec",
"taskkill"
"taskkill",
"where.exe"
],
"ignoreWorkspaces": [
"vendor/*",
@@ -230,6 +232,16 @@
"tests/**/*.ts"
]
},
"packages/bash/pwsh-sandbox": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/e2b/e2b": {
"entry": [
"tests/**/*.spec.ts",

View File

@@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor {
}
}
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
/**
* The pwsh invocation argv for one resolved spec — the argv-level seam a
* confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
* `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see
* `@deepseek-ai/dsh-pwsh-sandbox`).
*/
protected argv(spec: BashExecSpec): string[] {
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
}
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
private spawnSpec(
spec: BashExecSpec,
stdoutMaxBytes: number,
signal: AbortSignal | undefined,
argv: readonly string[],
): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
argv: [...argv],
cwd: spec.workdir,
stdio: {
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
@@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
return this.runArgv(spec, this.argv(spec))
}
/** Foreground run of an exact argv (the confining subclass re-wraps it). */
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv))
const outcome = await handle.done
const collected = PwshLocalExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
@@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor {
}
start(spec: BashExecSpec): BashProcess {
return this.startArgv(spec, this.argv(spec))
}
/** Background start of an exact argv (the confining subclass re-wraps it). */
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
const collected = PwshLocalExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
@@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote)
this.onProcessDone(proc, spawnFailureNote, true, error)
}),
readOutput: (): BashProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor {
/**
* Settlement hook for subclasses that attach execution facts to a process.
* The base implementation is intentionally empty. Mirrored from
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
* the protected extension point for a future pwsh-confining subclass and has no consumer
* in this package yet.
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the spawn rejected before any process existed.
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
*/
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md
README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2
README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
English | [中文](README.zh.md)
Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt.
The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy).
## Behavior
- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`.
- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`.
## Model Experience
### Confinement works, denial surfaces as command failure
#### What the model sees
The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool.
#### Token effect
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
#### KV Cache effect
None directly; the denial surface belongs to the tool layer.
## Known Limitations and Deferred Work
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap.
- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package).

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
[English](README.md) | 中文
沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。
执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。
## 行为
- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`
- 受限模式(`read-only``workspace-write`pwsh argv 由 `ctx.sandbox.confine()` 包装runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`
## 模型体验
### 隔离生效,拒绝以命令失败呈现
#### 模型看到什么
受限命令自身的 stderrWindows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
#### Token 影响
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
#### KV Cache 影响
无直接影响;拒绝呈现面属于工具层。
## 已知限制与后续工作
- **Windows 上读不受限**ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`
- **Windows workspace-write 的临时区域是真实临时目录**`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`同类seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-pwsh-sandbox",
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pwsh-local": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,120 @@
/**
* Internal sandbox-result classification helpers — deliberate call-for-call
* mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of
* the bash consumer shares the identical classification dialect).
*
* @module @deepseek-ai/dsh-pwsh-sandbox/helpers
*/
/* jscpd:ignore-start */
import { accessSync, constants, statSync } from 'node:fs'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
/** Whether the caller-owned spawn cwd can be entered. */
function isUsableWorkdir(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that
* argv[0] or its shebang interpreter.
* The workdir is checked at classification time, not atomically with spawn;
* concurrent path replacement may change attribution but cannot permit an
* unconfined execution.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
): boolean {
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
if (typeof error !== 'object' || error === null) return false
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
if (typeof syscall !== 'string') return false
const exactSyscall = `spawn ${runnerProgram}`
if (path === undefined) return syscall === exactSyscall
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
return syscall === 'spawn' || syscall === exactSyscall
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
* Classify a failed run against the selected backend's denial dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive denial substrings from the active wrap.
* @returns whether the failed run matches that denial dialect.
*/
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**
* Match a non-zero exit against case-insensitive stderr signatures.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text.
* @param signatures - substrings identifying the selected backend's dialect.
* @returns whether this is a non-zero exit whose stderr matches a signature.
*/
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
if (exitCode === null || exitCode === 0) return false
const lowered = stderr.toLowerCase()
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,189 @@
/**
* Sandbox-consuming PowerShell executor — the pwsh twin of
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
* chain), inherits local process mechanics, and reports the selected mode,
* enforcement, and denial facts. Positive runner-launch evidence means the
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool layer owns the escalation approval flow
* through `ctx.approval`; this executor reports the sandbox facts the tool
* renders.
* @module @deepseek-ai/dsh-pwsh-sandbox
*/
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The
* runner choice is likewise the `ctx.sandbox` provider's config, not this
* executor's.
*/
export type Config = LocalConfig
/**
* Registers as `ctx.bash` in place of the local pwsh executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
* sandbox denial rendering and escalation surface (see the
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
* resolved policy; direct calls fall back to deployment policy.
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
* renders.
*/
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
export class SandboxPwshExecutor extends PwshLocalExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
/**
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
* shared latest-wrap value would classify a process against the wrong facts.
* Unconfined processes have no entry.
*/
private readonly processFacts = new Map<BashProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
super(ctx, config)
// The default mode is the capability fact used for schema advertisement;
// actual tool executions carry their resolved per-call policy.
this.mode = ctx.sandboxPolicy.defaultMode
}
/** The configured default mode — the capability fact the tool layer reads. */
override get sandboxMode(): SandboxMode {
return this.mode
}
/**
* Stamp a complete per-call policy onto the spec. Tool calls supply the
* calling session's resolved mode and root; lower-level callers fall back to
* the deployment policy.
*/
override resolve(request: BashExecRequest): BashExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
const result = await super.run(spec)
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec, { ...policy, mode })
let result: BashRunResult
try {
result = await this.runArgv(spec, confined.argv)
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
override start(spec: BashExecSpec): BashProcess {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Once startArgv returns, install facts synchronously; promise settlement
// cannot run before start() returns.
const confined = this.confine(spec, { ...policy, mode })
let proc: BashProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
}
/**
* Stamp per-process sandbox facts before `done` settles. Full-access
* processes have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**
* Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param spec - resolved execution spec whose pwsh argv is confined.
* @param policy - resolved confined execution policy.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(this.argv(spec), policy)
}
}
/* jscpd:ignore-end */
export default SandboxPwshExecutor

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`.
* @module @deepseek-ai/dsh-pwsh-sandbox/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox'
/** Cordis companion plugin name. */
export const name = 'pwsh-sandbox-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or
* mutable data relation beyond contracts enforced at its owning seams.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,111 @@
/**
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
* REAL pwsh spawns confined through the runner — the debug-instance
* verification of both modes: read-only denies every write (not even NUL),
* workspace-write allows the workspace and temp while denying escape writes,
* and denial/classification facts ride the settled result.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
const isWin32 = process.platform === 'win32'
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
let scratchRoot!: string
let writableDir!: string
let isolatedTemp!: string
let secretFile!: string
let escapeFile!: string
let executor!: SandboxPwshExecutor
beforeAll(async () => {
// The escape probe must live OUTSIDE every legitimately granted tree: the
// provider's workspace-write grants the workspace plus the REAL temp dir
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
// scratch dir under temp would inherit the grant and the probe would be a
// false pass. A mkdtemp under the profile is removed by afterAll.
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
writableDir = join(scratchRoot, 'writable')
mkdirSync(writableDir)
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
secretFile = join(scratchRoot, 'secret.txt')
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
escapeFile = join(scratchRoot, 'escaped.txt')
const ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxPwshExecutor, {})
executor = ctx.bash as SandboxPwshExecutor
})
afterAll(() => {
rmSync(scratchRoot, { recursive: true, force: true })
rmSync(isolatedTemp, { recursive: true, force: true })
})
it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => {
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
].join('')
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
expect(result.stdout.text).toContain('TARGET-WRITE: DENIED')
expect(result.stdout.text).toContain('TEMP-WRITE: DENIED')
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
expect(result.stdout.text).toContain('SECRET-READ: OK')
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
// A self-caught denial keeps the command exit 0: no denial fact.
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
// A raw failing write must classify as a denial of the ACL dialect.
const denied = await executor.run(executor.resolve({
command: `Set-Content -Path '${escapeFile}' -Value x`,
sandboxPolicy: policy,
}))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 60_000)
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
].join('')
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
expect(result.stdout.text).toContain('SECRET-READ: OK')
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
expect(existsSync(escapeFile)).toBe(false)
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
}, 60_000)
})

View File

@@ -0,0 +1,326 @@
/**
* Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service
* makes wrapping, policy hand-off, fail-closed propagation, and fact stamping
* deterministic; real-provider integration lives in `tests/acl.e2e.ts`.
* Requires pwsh for the integration block (skips without it — same gate as
* pwsh-local's suites); the helpers block is pure and always runs.
*/
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { Context, Service } from 'cordis'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
// The same probe pwsh-local's suites and the vitest coverage exemption use:
// spawnSync never throws on a missing binary (it reports status null), and
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))
/** One recorded provider call: the argv handed over and the policy it rode with. */
interface ConfineCall {
argv: string[]
policy: SandboxPolicy
}
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] })
/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */
function throwingSubprocessService(error: unknown): new (ctx: Context) => Service {
return class extends Service {
constructor(ctx: Context) {
super(ctx, 'subprocess')
}
spawn(): never {
throw error
}
}
}
async function setup(
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
subprocess: new (ctx: Context) => Service = LocalSubprocessService,
): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
const calls: ConfineCall[] = []
class FakeSandboxProvider extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
calls.push({ argv: [...argv], policy })
return behavior(argv, policy)
}
}
const ctx = new Context()
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
await ctx.plugin(subprocess)
if (ctx.subprocess instanceof LocalSubprocessService) {
ctx.subprocess.internals = { spillDir }
}
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
return { executor: ctx.bash as SandboxPwshExecutor, calls }
}
describe('helpers (pure)', () => {
const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-'))
afterAll(() => {
rmSync(workdir, { recursive: true, force: true })
})
describe('isRunnerSpawnFailure', () => {
const absolute = process.execPath
const bare = 'node'
const relative = './sandbox-runner'
it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => {
for (const runnerProgram of [absolute, bare, relative]) {
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true)
}
})
it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => {
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false)
expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false)
// An existing FILE (not a directory) workdir is unusable without throwing.
const fileWorkdir = join(workdir, 'a-file')
writeFileSync(fileWorkdir, 'x')
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
const rules: readonly RunnerFailureRule[] = [{
allowedExitCodes: [127],
fatalSignatures: ['fake-runner: '],
informationalLines: ['fake-runner: partial enforcement'],
}]
it('matches a fatal signature on a gated exit code, skipping informational lines', () => {
expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules))
.toEqual({ detail: 'fake-runner: profile refused' })
})
it('rejects zero/null exits, gate mismatches, and empty signatures', () => {
expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined()
})
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
})
})
describe('matchesSignature', () => {
it('matches non-zero exits case-insensitively, never zero or signal exits', () => {
expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true)
expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true)
expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false)
expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false)
expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false)
})
})
})
describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
// Denial device for the POSIX classification cases: a mode-0555 directory
// INSIDE a temp scratch tree (the same device as bash-sandbox's suites) —
// unit tests never attempt writes outside the system temp directory. On
// win32 there is no POSIX mode denial; the real-sandbox denial coverage
// lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths.
const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-'))
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555)
const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')`
afterAll(() => {
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755)
rmSync(readOnlyDir, { recursive: true, force: true })
rmSync(spillDir, { recursive: true, force: true })
})
const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => {
const { executor, calls } = await setup()
const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO }))
expect(result.exitCode).toBe(0)
expect(calls).toHaveLength(1)
const call = calls[0]
expect(call?.policy).toEqual(RO)
// The confined argv is the pwsh invocation, ready for a runner prefix.
expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u)
expect(call?.argv).toContain('-NonInteractive')
expect(call?.argv.at(-1)).toContain('echo wrapped')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
}, 30_000)
it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => {
const { executor, calls } = await setup()
expect(executor.sandboxMode).toBe('workspace-write')
const result = await executor.run(executor.resolve({ command: 'echo fallback' }))
expect(result.exitCode).toBe(0)
expect(calls[0]?.policy.mode).toBe('workspace-write')
}, 30_000)
it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => {
const { executor, calls } = await setup()
const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } }))
expect(result.exitCode).toBe(0)
expect(calls).toHaveLength(0)
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
}, 30_000)
it('an aborted caller signal outranks runner-spawn attribution', async () => {
const controller = new AbortController()
controller.abort('caller-cancel')
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [],
}))
await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal })))
.rejects.toThrow('caller-cancel')
}, 30_000)
// POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the
// real-sandbox denial classification is covered by tests/acl.e2e.ts
// (the ACL runner denies scratch paths — unit tests never leave temp).
it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => {
const { executor } = await setup()
const result = await executor.run(executor.resolve({
command: deniedWriteCommand,
sandboxPolicy: RO,
}))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 30_000)
it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => {
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
}, 30_000)
it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => {
const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
const { executor: closed } = await setup(() => ({
argv: ['node', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}), throwingSubprocessService(attributable))
await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
.rejects.toThrow('sync-emfile')
}, 30_000)
it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => {
const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
const { executor: closed } = await setup(() => ({
argv: ['node', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}), throwingSubprocessService(attributable))
expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
.toThrow(SandboxUnavailableError)
const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
.toThrow('sync-emfile-start')
}, 30_000)
it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => {
const { executor } = await setup(() => ({
argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
}, 30_000)
it('background confined runs stamp clean facts at settlement', async () => {
const { executor } = await setup()
const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO }))
await clean.done
expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
}, 30_000)
// POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial
// coverage lives in tests/acl.e2e.ts.
it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => {
const { executor } = await setup()
const denied = executor.start(executor.resolve({
command: deniedWriteCommand,
sandboxPolicy: RO,
}))
await denied.done
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 30_000)
it('background spawn rejections settle as runnerFailed facts', async () => {
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
await proc.done
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
// The failure note surfaces through the read path.
const read = proc.readOutput()
expect(read.delta).toContain('spawn failed')
}, 30_000)
it('danger-full-access background runs bypass confine and carry no facts', async () => {
const { executor, calls } = await setup()
const proc = executor.start(executor.resolve({
command: 'echo full-bg',
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
}))
await proc.done
expect(calls).toHaveLength(0)
expect(proc.sandbox).toBeUndefined()
}, 30_000)
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
},
{
"path": "../../bash/pwsh-local"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
README.md: 7d8ee5fb69b71d8e8707d3e4ed07ebdda99f799f
README.zh.md: 40984bbc36be4b5809e6ee4db21e52d842f50cdb
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker).
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
@@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. |
| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
@@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
@@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict
#### What the model sees
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
#### Token effect
@@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
#### Token effect
@@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only.
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
@@ -21,6 +21,8 @@
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
| `workdir` | string | 本次调用的工作目录。默认取调用 agent智能体的会话 cwd`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
| `run_in_background` | boolean | 立即返回 task id不适用超时。 |
| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed不运行任何内容。 |
| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 |
`command``workdir``timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`
@@ -28,9 +30,9 @@
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
结果文本包含 stdout、可选的 `[stderr]`然后是适用的截断、超时、signal 与退出 marker。干净退出0、无 signal不产生 marker空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出0、无 signal不产生 marker空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
`run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
@@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be
#### What the model sees
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]``[timed out after <timeoutMs>ms]``[killed by signal: <signal>]``[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]``[sandbox: file access denied under <mode> mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after <timeoutMs>ms]``[killed by signal: <signal>]``[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`
#### Token effect
@@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。
#### What the model sees
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``run_in_background is disabled for this deployment (enableRunInBackground: false)``background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks``tool call aborted`
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``invalid escalation: sandbox_permissions requires a justification``invalid escalation: justification is only valid together with sandbox_permissions``invalid justification: expected a non-empty sentence``sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)``background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks``tool call aborted`
#### Token effect
@@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
## Known Limitations and Deferred Work
- ** sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器bash 工具的 sandbox 面不被镜像)
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端仅限 Linux/macOS。
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用read-only 或 workspace-write受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作
- **PowerShell 方言约定** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决

View File

@@ -30,9 +30,12 @@
"@deepseek-ai/dsh-bash-env": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -46,12 +49,15 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -4,13 +4,17 @@
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
*
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
* foreground and `run_in_background` execution (background handles register
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
* through the shared `bash-env` registry, and the bash marker/truncation
* rendering story. UI presentation mirrors the bash tool's too: a completed
* foreground call is a terminal card with the parsed exit-status pill, using
* the shared exit-status parse from `@deepseek-ai/dsh-bash`.
* Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
* `run_in_background` execution (background handles register with the
* generic `ctx.tasks` runtime), the managed `DSH_*` environment through the
* shared `bash-env` registry, the per-call sandbox policy resolution (the
* calling session's mode and cwd travel to the confining executor), the
* sandbox-denial rendering with the same-turn escalation surface
* (`sandbox_permissions` + `justification` resolved through
* `ctx.approval`), and the bash marker/truncation rendering story. UI
* presentation mirrors the bash tool's too: a completed foreground call is
* a terminal card with the parsed exit-status pill, using the shared
* exit-status parse from `@deepseek-ai/dsh-bash`.
*
* @module @deepseek-ai/dsh-tool-pwsh
*/
@@ -19,16 +23,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-bash-env'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import { parseExitStatus } from '@deepseek-ai/dsh-bash'
import { processOutcome } from './background.ts'
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
import type { RenderablePwshResult } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -57,6 +66,8 @@ interface PwshToolArgs {
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string
justification?: string
}
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
@@ -69,6 +80,7 @@ interface PwshForegroundResult {
timeoutMs: number
stdout: { text: string; truncated: boolean; spillPath?: string }
stderr: { text: string; truncated: boolean; spillPath?: string }
sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean }
}
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
@@ -82,21 +94,54 @@ function validatePwshArgs(args: PwshToolArgs): void {
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
}
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
// the shared rule both enforcing families validate identically.
validateEscalationArgs(args.sandbox_permissions, args.justification)
}
/* jscpd:ignore-end */
function pwshDescription(backgroundEnabled: boolean): string {
function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
+ background
if (escalationModes.length === 0) return base
// The CLM and named-pipe contracts below are Windows-restricted-token
// behavior, but the gate is 'any confining executor is mounted'
// (escalationModes non-empty). The conflation is safe today because every
// shipped composition pairing tool-pwsh with a confining executor is
// win32-only; a future POSIX pwsh-sandbox composition must gate both
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
// Agent Note).
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
+ 'do not retry the command another way — escalate the exact command once or restructure it to '
+ 'avoid capturing output. '
+ 'Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
+ 'A rejected escalation is final for that command — stop and explain, never work around '
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/**
@@ -129,6 +174,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
@@ -139,8 +192,55 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
} as const
/* jscpd:ignore-end */
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && sandboxPolicy === undefined) {
throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}
/* jscpd:ignore-end */
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes, delegating the shared fail-closed sequence (strict
* widening, channel resolution, outcome mapping) to
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval
* ingredients. The shared policy resolver is required whenever the
* executor advertises confinement, so a split composition fails at
* tool-plugin load.
*/
const approvePwshEscalation = (
mode: string,
justification: string,
exec: ToolExecution,
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
approver: ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName: 'pwsh',
signal: exec.signal,
},
)
}
/* jscpd:ignore-end */
ctx.systemPrompt.section({
name: 'tool:pwsh',
@@ -151,7 +251,8 @@ export function apply(ctx: Context, config: Config = {}): void {
ctx.tools.register(defineTool({
name: 'pwsh',
description: pwshDescription(backgroundEnabled),
description: pwshDescription(backgroundEnabled, escalationModes),
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */
parameters: {
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
description: {
@@ -166,7 +267,19 @@ export function apply(ctx: Context, config: Config = {}): void {
...backgroundEnabled ? {
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
} : {},
...escalationModes.length > 0 ? {
sandbox_permissions: {
type: 'string' as const,
enum: [...escalationModes],
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string' as const,
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
},
} : {},
},
/* jscpd:ignore-end */
output: {
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
// consumers of one must accept the other (see the pwsh-tool-and-executor
@@ -209,6 +322,16 @@ export function apply(ctx: Context, config: Config = {}): void {
spillPath: { type: 'string' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
@@ -218,18 +341,27 @@ export function apply(ctx: Context, config: Config = {}): void {
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderPwshResult(value),
: renderPwshResult(value as RenderablePwshResult, escalationModes),
}],
},
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
async execute(args: PwshToolArgs, exec) {
validatePwshArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
const standingPolicy = resolveSandboxPolicy(exec)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv: ctx.bashEnv.collect(exec),
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
if (args.run_in_background === true) {
// Undeclared keys are allowed, so schema omission also needs enforcement.
@@ -241,15 +373,11 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until ctx.tasks commits detached ownership.
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
/* v8 ignore end */
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'pwsh',
@@ -260,7 +388,7 @@ export function apply(ctx: Context, config: Config = {}): void {
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderPwshProcessRead(proc.readOutput()),
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})

View File

@@ -1,17 +1,20 @@
/**
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
* stderr section, truncation notices with spill paths, then exit-status
* markers. Non-zero exits are reported, not errored — the model decides how to
* react; only infrastructure failures (spawn errors, aborts) surface as
* isError results.
* `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox
* denial/runner-failure markers (with the same-turn escalation hint), and
* truncation notices with spill paths, then exit-status markers. Non-zero
* exits are reported, not errored — the model decides how to react; only
* infrastructure failures (spawn errors, aborts) surface as isError
* results.
*
* @module @deepseek-ai/dsh-tool-pwsh/render
*/
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
@@ -27,6 +30,7 @@ export interface RenderablePwshResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
sandbox?: BashSandboxInfo
}
/**
@@ -34,9 +38,15 @@ export interface RenderablePwshResult {
* stderr section, then exit-status markers, matching the bash tool's story —
* a clean exit (0, no signal) produces no marker.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderPwshResult(result: RenderablePwshResult): string {
export function renderPwshResult(
result: RenderablePwshResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
@@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string {
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push(escalationHintMarker('command'))
}
}
// A command may trap the termination and exit 0 after timeout; still report interruption.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
@@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string {
* sees: the incremental delta, plus the lossy-read notice (with full-stream
* spill paths) when in-memory truncation dropped unread bytes.
* @param read - one incremental read from the process handle.
* @returns the delta text with any loss notice appended.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderPwshProcessRead(read: BashProcessRead): string {
export function renderPwshProcessRead(
read: BashProcessRead,
sandbox?: BashSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): string {
const notices: string[] = []
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
}
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}

View File

@@ -5,13 +5,14 @@
* text, truncation, timeout, abort, nonzero exits, background handles — so
* these tests verify the schema, argument validation, workdir derivation,
* managed `DSH_*` collection, abort translation, canonical result projection,
* rendering, background task wiring, and the UI presenters. Real-pwsh behavior
* sandbox denial rendering with the escalation surface, rendering,
* background task wiring, and the UI presenters. Real-pwsh behavior
* is pinned separately in integration.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtempSync } from 'node:fs'
import { mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -22,8 +23,11 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
@@ -150,9 +154,106 @@ async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome
return { ctx, bash }
}
/**
* A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve
* the calling session's standing policy and stamp it on the request, exactly
* like the bash tool — the per-session sandbox-policy regression surface.
* Records each confined mode and returns scriptable sandbox facts so the
* escalation and rendering surfaces are testable without a real backend.
*/
class ConfiningFakeBash extends BashExecutor {
requests: BashExecRequest[] = []
modes: Array<string | undefined> = []
override get sandboxMode() {
return 'read-only' as const
}
override resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
this.modes.push(spec.sandboxPolicy?.mode)
return runResult('ok\n', {
sandbox: {
mode: spec.sandboxPolicy?.mode ?? 'read-only',
denied: false,
...spec.command === 'without optional sandbox facts'
? {}
: { enforcement: 'full' as const, runnerFailed: false },
},
})
}
override start(spec: BashExecSpec): BashProcess {
this.modes.push(spec.sandboxPolicy?.mode)
return fakeProcess()
}
}
/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */
async function setupSandboxed(withApproval = false) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(ConfiningFakeBash)
if (withApproval) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolPwsh)
const bash = ctx.bash as ConfiningFakeBash
return { ctx, bash }
}
/**
* Build a fake {@link Agent} whose session log carries the sandbox-policy
* mode-override event the escalation flow evaluates against, with an
* appendable log (the approval service records decisions through
* `session.append`).
*/
function sandboxAgent(
mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
ctx?: Context,
onAppend?: (type: string) => void,
): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
return {
id,
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
session: {
id,
header: { version: 0, id, createdAt: 0 },
events,
append: (type: string, data: Record<string, unknown>) => {
const event = { type, data }
events.push(event)
onAppend?.(type)
return event
},
},
} as unknown as Agent
}
/**
* Build a fake {@link Agent} with the shared agent/session identity, give it a
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
* The fake session carries an empty event log (the sandbox-policy resolver
* folds the log for mode overrides, mirroring a real session).
*/
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
const scopeFiber = ctx.plugin(() => {})
@@ -160,7 +261,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent {
const agent = {
id,
ctx: scopeFiber.ctx,
session: { id, header: { version: 0, id, createdAt: 0 } },
session: { id, header: { version: 0, id, createdAt: 0 }, events: [] },
} as unknown as Agent
ctx.agents.register(agent)
return agent
@@ -397,6 +498,203 @@ describe('execution through the bash seam', () => {
})
})
describe('per-call sandbox policy resolution', () => {
it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
const { ctx, bash } = await setupSandboxed()
const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
const agent = registerFakeAgent(ctx, 'policy-session')
Object.assign(agent.session.header, { cwd: sessionCwd })
const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
expect(result.isError).toBe(false)
// The policy's workspace root is the session cwd canonicalized by the
// policy service (realpath + resolve), NEVER the web server's launch dir;
// the calling session's identity rides along for backend per-session state.
expect(bash.requests[0]?.sandboxPolicy).toEqual({
mode: 'read-only',
workspaceRoot: resolvePath(realpathSync.native(sessionCwd)),
sessionId: 'policy-session',
})
})
it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => {
const { ctx, bash } = await setupSandboxed()
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
expect(bash.requests[0]?.sandboxPolicy).toEqual({
mode: 'read-only',
workspaceRoot: resolvePath(realpathSync.native(process.cwd())),
})
// The base FakeBash advertises no sandboxMode, so the tool must not stamp
// any policy (the executor defaulting stays the executor's own).
const plain = await setup()
await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy')
})
it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ConfiningFakeBash)
await expect(ctx.plugin(ToolPwsh)).rejects.toThrow(
'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing',
)
})
})
describe('sandbox escalation through ctx.approval', () => {
const escalate = {
command: 'Write-Output ok',
description: 'test escalation',
sandbox_permissions: 'workspace-write',
justification: 'the command needs workspace writes',
}
it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => {
const { ctx } = await setupSandboxed()
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
expect(schema.description).toContain('approval prompt')
expect(schema.description).toContain('ConstrainedLanguage')
expect(schema.description).toContain('named pipes')
expect(schema.description).toContain('fails with EPERM')
for (const args of [
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' },
{ command: 'Write-Output ok', description: 'd', justification: 'why' },
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
]) {
expect((await call(ctx, 'pwsh', args)).isError).toBe(true)
}
})
it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => {
const { ctx } = await setup()
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
expect(schema.description).not.toContain('ConstrainedLanguage')
expect(schema.description).not.toContain('named pipes')
expect(schema.description).not.toContain('sandbox_permissions')
expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
})
it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
const plain = await setup()
expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
const { ctx } = await setupSandboxed(true)
const prompted = vi.fn()
ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
expect(text(result)).toContain('not strictly wider')
expect(prompted).not.toHaveBeenCalled()
const malformed = sandboxAgent()
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
type: 'sandbox/mode',
data: { mode: 'unknown-mode' },
})
expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
})
it('fails closed when approval cannot be routed', async () => {
const withoutService = await setupSandboxed()
expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')
const withService = await setupSandboxed(true)
expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route')
expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel')
})
it.each([
['rejected', 'user rejected'],
['cancelled', 'was cancelled'],
] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
const { ctx, bash } = await setupSandboxed(true)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
expect(text(result)).toContain(message)
expect(bash.modes).toEqual([])
})
it('runs a granted foreground or background call under the approved mode', async () => {
const { ctx, bash } = await setupSandboxed(true)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const agent = sandboxAgent(undefined, ctx)
ctx.agents.register(agent)
const foreground = await ctx.tools.execute({
callId: CallId('sandbox-signal'),
name: 'pwsh',
arguments: escalate,
agent,
signal: new AbortController().signal,
})
expect(foreground.isError).toBe(false)
const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent)
expect(text(background)).toBe('started background task pwsh-1')
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
})
it('does not publish detached work when cancellation follows the escalation grant', async () => {
const { ctx, bash } = await setupSandboxed(true)
const controller = new AbortController()
const agent = sandboxAgent(undefined, ctx, (type) => {
if (type === 'approval/decided') controller.abort()
})
ctx.agents.register(agent)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const start = vi.spyOn(bash, 'start')
const result = await ctx.tools.execute({
callId: CallId('cancelled-escalation-background'),
name: 'pwsh',
arguments: { ...escalate, run_in_background: true },
agent,
signal: controller.signal,
})
expect(result.error).toEqual({
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(text(result)).toBe('Error: tool call aborted')
expect(start).not.toHaveBeenCalled()
})
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
const { ctx, bash } = await setupSandboxed(true)
const agent = sandboxAgent('workspace-write')
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
})
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
const { ctx } = await setupSandboxed()
const result = await call(ctx, 'pwsh', {
command: 'without optional sandbox facts',
description: 'exercise optional sandbox facts',
})
if (result.isError) throw new Error('expected foreground pwsh success')
expect(result.value).toMatchObject({
kind: 'foreground',
sandbox: { mode: 'read-only', denied: false },
})
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
})
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
})
})
describe('background execution through the task runtime', () => {
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
const { ctx } = await setupWithTasks()
@@ -641,6 +939,35 @@ describe('UI presentation', () => {
})
})
describe('renderPwshResult sandbox markers', () => {
const base = {
exitCode: 0,
signal: null,
timedOut: false,
timeoutMs: 1000,
stdout: { text: 'out\n', truncated: false },
stderr: { text: '', truncated: false },
}
it('a denied run reports the denial marker before the exit marker', () => {
expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } }))
.toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]')
})
it('hints only when the composition advertises escalation', () => {
const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } }
expect(renderPwshResult(denied, ['workspace-write'])).toBe(
'out\n[sandbox: file access denied under read-only mode]\n'
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]',
)
})
it('a confined run without a denial adds no sandbox marker', () => {
expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n')
})
})
describe('renderPwshProcessRead', () => {
const base: BashProcessRead = { delta: 'out\n', lossy: false }
@@ -677,6 +1004,20 @@ describe('renderPwshProcessRead', () => {
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
})
it('appends the runner-failed notice (denial outranked)', () => {
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true }))
.toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]')
})
it('appends the denial marker and hints only when escalation is advertised', () => {
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }))
.toBe('x\n[sandbox: file access denied under read-only mode]')
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write']))
.toBe('x\n[sandbox: file access denied under read-only mode]\n'
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
})
})
describe('processOutcome', () => {

View File

@@ -38,6 +38,18 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../bash/bash-env"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../support/invariants"
}

View File

@@ -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/bundle/base/README.md
README.md: aea28a7412fd1123e5bbc593fb7978b2e4595248
README.zh.md: f11745061c340d23401729ff326d277a0eade428
README.md: d9dbf717d1d11adce2eae9498d4bfa596a46fd3e
README.zh.md: 9c0d47f7e4abc1b0636bd2097ea3e2bc8c826b81

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local``@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
## Model Experience
@@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect.
## Known Limitations and Deferred Work
- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer.
- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`<temp>\dsh-<hash>`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`.

View File

@@ -4,6 +4,8 @@
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settingscredentials、repository 插件、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 APIprofile 组合器通过 manifest元数据清单`dsh.bundle.patch` 字段解析 patch绝不通过代码。
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox``@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud。POSIX 主机永远不会收到它。
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
## 模型体验
@@ -17,3 +19,4 @@
## 已知限制与延期工作
- **patch 会替换整行 `config`**profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。
- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`<temp>\dsh-<hash>`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`

View File

@@ -16,6 +16,7 @@
"default": "./lib/invariant.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -23,6 +24,7 @@
"lib/index.js",
"lib/invariant.js",
"cordis.patch.yml",
"windows.cordis.patch.yml",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
@@ -46,6 +48,7 @@
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
@@ -57,6 +60,7 @@
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
@@ -89,6 +93,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",

View File

@@ -13,15 +13,22 @@ import { entryListSchema } from '@cordisjs/plugin-include'
describe('dsh-base bundle', () => {
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
const root = fileURLToPath(new URL('..', import.meta.url))
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
const manifest = JSON.parse(
readFileSync(resolve(root, 'package.json'), 'utf8'),
) as {
dependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string } }
}
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema })
const parsed = yaml.load(
readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
{ schema: entryListSchema },
)
expect(Array.isArray(parsed)).toBe(true)
// The base layer is one insert list over the empty profile root.
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? [])
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
patch => patch.insert ?? [],
)
expect(rows.length).toBeGreaterThan(50)
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1)
@@ -31,4 +38,35 @@ describe('dsh-base bundle', () => {
'@deepseek-ai/dsh-subagent-claude-code': 'workspace:^',
})
})
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
const root = fileURLToPath(new URL('..', import.meta.url))
const parsed = yaml.load(
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
{ schema: entryListSchema },
) as {
id?: string
disabled?: boolean
insert?: { id?: string; name?: string }[]
config?: { policy?: string }
}[]
const disables = parsed
.filter(patch => patch.disabled === true)
.map(patch => patch.id)
// Only the POSIX bash stack is disabled: the Windows roster confines the
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
// the permission switcher, fs-sandbox, and the approval service all stay
// enabled exactly as on POSIX — only the shell is swapped.
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
const inserted = parsed
.flatMap(patch => patch.insert ?? [])
.map(row => row.id)
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
// The patch no longer touches the permission/approval surface at all.
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
})
})

View File

@@ -0,0 +1,31 @@
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
# hosts, between the bundle layers and the user layers. Windows confines
# through the ACL restricted-token runner (the win32 chain of
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
# stack is the SANDBOXED PowerShell executor plus the full permission
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
# permission switcher and the approval service run exactly as on POSIX, and
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
# Only the POSIX bash
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
# A Windows host that prefers the unconfined local pwsh executor or full
# access overrides these rows through its profile or home cordis.patch.yml.
# The bash-restore recipe must be complete: disable pwsh-sandbox and
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
# families register the same 'bash' service, so re-enabling the bash rows
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
# registration.
- id: bash-sandbox
disabled: true
- id: tool-bash
disabled: true
- insert:
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: b57f88b5a030a6c20c957e26ea32fb125f106ab4
README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7
README.md: f684f99c9e80a02e3ccad57c9a4d7246df0b192b
README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd

View File

@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')`:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。草稿为空时Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。

View File

@@ -152,7 +152,7 @@ export function apply(ctx: Context): void {
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
const inputHub = new InputHub(ctx, t)
// The composer-block registry: a plugin that knows a session cannot send —
// ui-model, when no adapter serves the session's route — raises a block

View File

@@ -88,6 +88,12 @@ export interface ComposerKeyboard {
setDraft(text: string, editRange?: EditRange): void
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
submit(mode: InputSubmitMode): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
* button is the same operation applied to the whole queue).
*/
steerQueue(): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */

View File

@@ -39,6 +39,11 @@ export interface SessionInputDeps {
popup?: (() => PopupDismissFace | undefined) | undefined
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order (the empty-draft accelerated-Enter gesture); absent = unsupported.
*/
steerQueue?: (() => void) | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: InputSubmitMode): void
}
@@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput {
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
}
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture). Execution belongs to the hub's
* queue choreography; absent dep = the gesture falls back to the machine's
* empty-draft no-op.
*/
steerQueue(): void {
this.deps.steerQueue?.()
}
/**
* Space adjudication over the controller's hot state.
* @returns true = a claim/insert was applied — the caller preventDefaults.

View File

@@ -10,6 +10,7 @@
*/
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -25,8 +26,14 @@ interface CommandFace {
export class InputHub implements InputService {
private readonly shells = new Map<SessionId, SessionInputShell>()
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
constructor(private readonly rootCtx: ClientContext) {}
/**
* @param ctx - client root context (services resolved lazily per call — boot order stays free).
* @param t - conversation-namespace translate thunk (reads the active locale at call time).
*/
constructor(
private readonly rootCtx: ClientContext,
private readonly t: TranslateNS<'conversation'>,
) {}
/**
* Resolve the facade for one session-scope ctx (InputService face).
@@ -58,6 +65,7 @@ export class InputHub implements InputService {
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
steerQueue: () => { void this.steerQueue(session, shell) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -139,6 +147,30 @@ export class InputHub implements InputService {
)
}
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order — the same strict-steer operation as the queue dock's per-row
* button. A turn closing mid-way (`steer-unavailable`) or a row already
* claimed by the agent (`queue-item-not-found`) converges silently, while a
* genuine failure surfaces as one composer notice. Repeated triggers
* (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found`
* convergence: the snapshot may still list a row the host already steered,
* and the duplicate strict steer is a silent no-op.
* @param session - the addressed host session.
* @param shell - the resident shell (notice outlet).
*/
private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
if (queued.length === 0) return
for (const item of queued) {
const result = await session.updateQueue(item.id, { kind: 'steer' })
if (result.ok) continue
if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return
shell.notify('error', this.t('queue.steerFailed'))
return
}
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash')
return slash?.sessionOf(actx)

View File

@@ -23,6 +23,7 @@ export const zh = {
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
@@ -166,6 +167,7 @@ export const en = {
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'context.aria': '{percent} of context used',
'context.used': 'of context used',

View File

@@ -99,6 +99,8 @@ export function InputBar({
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
&& input.queue.some(row => row.placement === 'queued')
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
@@ -257,9 +259,19 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
const accelerated = e.ctrlKey || e.metaKey
// Empty-draft accelerated Enter acts on the queue instead of the (empty)
// draft: the machine rejects empty drafts, so the gesture steers every
// still-pending queued message into the running turn (the dock's per-row
// steer button applied to the whole queue). Steering needs the same
// window as the per-row button: a running ordinary session.
if (accelerated && canSteerQueue) {
keyboard.steerQueue()
return
}
keyboard.submit(resolveSubmitMode(
running,
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
accelerated ? 'accelerated' : 'enter',
subagent === null,
))
}
@@ -489,7 +501,12 @@ export function InputBar({
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
// The steer hint deliberately outranks the plan placeholder:
// while it shows, the whole-queue gesture is genuinely available
// (the gate never consults plan mode), so the actionable hint wins.
: canSteerQueue
? t('placeholder.steerQueue')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}

View File

@@ -57,6 +57,10 @@ interface BenchOptions {
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
/** Authoritative queue rows served to the machine overlay (empty = none). */
queue?: ConversationSnapshot['queue']
/** The hub's steer-all face (empty-draft accelerated Enter). */
steerQueue?: () => void
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
@@ -70,14 +74,34 @@ interface BenchOptions {
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
function row(id: string): ConversationSnapshot['queue'][number] {
return {
id: id as never, messageId: `message-${id}` as never, placement: 'queued',
content: [{ type: 'text', text: id }], preview: id, text: id,
}
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
queue: over?.queue ?? [],
}))
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
queue: {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
},
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
@@ -89,12 +113,6 @@ function bench(over?: BenchOptions) {
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
@@ -151,10 +169,62 @@ function bench(over?: BenchOptions) {
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
steerQueue: over?.steerQueue,
}
}
describe('Enter semantics', () => {
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
subagent: {
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
},
}).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
placeholder: '上层指定提示',
}).textarea.placeholder).toBe('上层指定提示')
// The command menu owns Enter while open: neither the hint nor the
// gesture may claim the chord.
expect(bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
}).textarea.placeholder).toBe('给智能体发消息')
// The steer hint intentionally outranks the plan placeholder: while it
// shows, the whole-queue gesture is genuinely available in plan mode.
expect(bench({
running: true,
queue: [row('q-1')],
plan: { active: true, pending: false },
}).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('an open command menu withholds the whole-queue steering gesture', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
steerQueue,
})
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(steerQueue).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
})
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -194,6 +264,78 @@ describe('Enter semantics', () => {
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
})
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
const steerQueue = vi.fn()
const queue = [row('q-1'), row('q-2')]
const meta = bench({ running: true, queue, steerQueue })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
expect(meta.sink).not.toHaveBeenCalled()
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
expect(ctrl.sink).not.toHaveBeenCalled()
})
it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
// Idle: the gesture falls through to the machine's empty-draft no-op.
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.steerQueue).not.toHaveBeenCalled()
expect(idle.sink).not.toHaveBeenCalled()
// Plain Enter never steers the queue, even under the busy Steer preference.
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.steerQueue).not.toHaveBeenCalled()
expect(plain.sink).not.toHaveBeenCalled()
// Subagent sessions keep the queue transport (no steering face).
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
expect(child.steerQueue).not.toHaveBeenCalled()
expect(child.sink).not.toHaveBeenCalled()
// No queued rows: the empty draft stays a no-op.
const none = bench({ running: true, steerQueue: vi.fn() })
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
expect(none.steerQueue).not.toHaveBeenCalled()
expect(none.sink).not.toHaveBeenCalled()
// Pending steering rows are not the queue: nothing to flush.
const steering = bench({
running: true,
queue: [{ ...row('s-1'), placement: 'steering' }],
steerQueue: vi.fn(),
})
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
expect(steering.steerQueue).not.toHaveBeenCalled()
expect(steering.sink).not.toHaveBeenCalled()
})
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(sink).toHaveBeenCalledWith('插话', 'steer')
expect(steerQueue).not.toHaveBeenCalled()
})
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(sink).not.toHaveBeenCalled()
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })

View File

@@ -6,9 +6,12 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { zh } from '../src/client/locales.ts'
async function bench() {
const runtime = await SlotTestRuntime.create()
@@ -22,14 +25,16 @@ async function bench() {
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
input: hub,
blocks: new ComposerBlockRegistry(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -87,10 +92,88 @@ describe('ConversationService', () => {
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,
messageId: `message-${id}` as never,
placement: 'queued',
content: [{ type: 'text', text: id }],
preview: id,
text: id,
})
it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
})
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.updateQueue).toHaveBeenCalledTimes(2)
})
expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' })
expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
// The turn closes before the second row: the flush stops, silently.
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
expect(b.shell.notices.getSnapshot()).toBeNull()
// A row the host already claimed (e.g. a repeated empty-draft chord):
// the duplicate strict steer is a silent no-op.
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-3')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('surfaces one notice on a genuine steer failure and stops', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.shell.notices.getSnapshot()).toEqual(
expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }),
)
})
expect(b.updateQueue).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('no-ops without queued rows', async () => {
const b = await bench()
b.shell.steerQueue()
expect(b.updateQueue).not.toHaveBeenCalled()
await b.runtime.dispose()
})
})

View File

@@ -215,7 +215,7 @@ describe('LocalPtyBackend startup rollback', () => {
expect(initialized).toHaveBeenCalledWith(undefined)
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: '/workspace' },
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' },
}])
})
@@ -247,7 +247,7 @@ describe('LocalPtyBackend startup rollback', () => {
})
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' },
}])
})

View File

@@ -142,7 +142,7 @@ describe('pty-local real shell', () => {
const created = await ctx.pty.spawn(agent, { type: 'shell' })
expect(sandbox.calls).toEqual([{
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) },
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' },
}])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-sandbox-local",
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed",
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -28,9 +28,11 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^",
"@deepseek-ai/node-addon-landlock-run": "workspace:*",
"schemastery": "^3.18.0"
},
@@ -38,6 +40,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,12 +1,29 @@
/**
* 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 classification facts. Missing or unusable
* confinement fails closed rather than returning the original argv.
* Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes
* competing candidates once, and reports each wrap's enforcement and stderr
* classification facts. Missing or unusable confinement fails closed rather
* than returning the original argv.
*
* The windows-acl rung additionally owns the write grants: the write SID is
* the per-WORKSPACE identity derived from the canonical workspace path
* (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per
* session (session id + workspace — nothing stored). The
* workspace-root ACE materializes once per workspace per server lifetime
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
* every later provision O(1) instead of re-propagating the tree per
* session); the private-temp ACEs are revoked on dispose. The runner
* receives `--write-sid` (the derived identity; its presence marks the
* seam-managed contract) and stops managing DACLs itself.
* @module @deepseek-ai/dsh-sandbox-local
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
LAUNCHER_BIN,
LAUNCHER_FAILURE_EXIT,
@@ -18,6 +35,8 @@ import z from 'schemastery'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -70,6 +89,46 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean
return probe.status === 0
}
/**
* Functional windows-acl probe: run the runner in read-only mode (zero grants,
* no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created
* the restricted token and spawned the child under it. The win32 chain is a
* sole candidate, so the product never probes; the probe exists for override
* chains and mirrors the other rungs' shape.
*/
function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean {
const program = runnerInvocation[0]
if (program === undefined) return false
const probe = spawnSync(program, [
...runnerInvocation.slice(1),
'--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only',
'--', 'cmd', '/c', 'exit', '0',
], {
timeout: timeoutMs,
stdio: 'ignore',
})
return probe.status === 0
}
/**
* The session's private temp subdirectory: `<tmpdir>\dsh-<16 hex>`, derived
* from the session id and its workspace instead of stored. The same session
* and workspace always name the same directory — a resumed session
* re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's
* different session id names a fresh one. The name is predictable to anyone
* who knows the session id (the confined command sees it as
* `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and
* rejects reparse points: a pre-placed entry fails the first confined run
* loudly, and cannot redirect the grant onto a foreign object.
* @param sessionId - the policy's calling-session identity.
* @param workspaceRoot - the resolved policy root.
* @returns the session's private temp subdirectory path.
*/
export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string {
const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex')
return join(tmpdir(), `dsh-${digest.slice(0, 16)}`)
}
/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
export interface SandboxInternals {
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
@@ -86,10 +145,18 @@ export interface SandboxInternals {
landlockLauncher?: string
/** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
seatbeltExec?: string
/** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
windowsAclRunnerArgs?: string[]
/** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */
windowsAclRunnerEntry?: string
/** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */
probeWindowsAcl?: () => boolean
/** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */
rmTempDir?: (path: string) => void
}
/** The chain's verdict: which runner confines, and how completely it enforces. */
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement }
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }
/**
* The runner chain per platform — selection is BY PLATFORM first, probes
@@ -103,11 +170,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement:
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
linux: ['bwrap', 'landlock'],
darwin: ['seatbelt'],
// Reserved slot, deliberately empty: Windows support fills it with a confinement runner
// (AppContainer / restricted-token family, shipped from its own repository on the
// landlock-run template) plus a SelectedRunner['runner'] union member — the switches'
// assertNever guards then walk the implementer to every site.
win32: [],
// The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):
// a sole candidate, selected without a probe — its execution-time refusal
// fails closed through its stderr signature (windows-acl-run:) and exit 127.
win32: ['windows-acl'],
}
/**
@@ -123,6 +189,13 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> =
bwrap: 'full',
landlock: 'full',
seatbelt: 'full',
// 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists
// close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are
// absent from both — pinned by the runner's Public-probe and CIM-denial
// regressions). FAT-class (non-ACL) targets are declared unsupported
// (warn-only) in the backend README — outside the promise, not an
// exception to it.
'windows-acl': 'full',
}
/**
@@ -145,15 +218,26 @@ const DENIAL_SIGNATURES = {
bwrap: ['read-only file system'],
landlock: ['permission denied'],
seatbelt: ['operation not permitted'],
// pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied.";
// node EACCES: "permission denied".
'windows-acl': ['access is denied', 'access to the path', 'permission denied'],
runnerCommand: ['read-only file system', 'permission denied'],
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */
const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127
/**
* 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
* The windows-acl runner prints `windows-acl-run: <detail>` on every
* runner-side failure and exits 127 — the rule is exit-gated on that status
* so a confined command that merely PRINTS the signature (or a runner
* cleanup failure reported on a non-zero child exit) is never misclassified
* as "the command did not run". Keep the Landlock tuple aligned with the
* assembled snapshot fixture at
* `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`.
*/
const RUNNER_FAILURE_RULES = {
@@ -164,12 +248,15 @@ const RUNNER_FAILURE_RULES = {
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
}],
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }],
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
* apart from the cached chain verdict — it spawns nothing but the one-time
* probes, so there is no disposal work beyond cordis' own.
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
* chain verdict and, on the windows-acl rung, the write grants
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
* and the revocable private-temp grant per session, the latter revoked on
* provider dispose); the one-time probes spawn nothing else.
*/
export class LocalSandboxProvider extends SandboxProvider {
// Inline schema call: the config catalog walks `static Config` statically.
@@ -187,6 +274,16 @@ export class LocalSandboxProvider extends SandboxProvider {
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
/**
* Server-lifetime write grants (windows-acl rung): the STANDING
* workspace-root grant per workspace (its ACE is the cross-session reuse
* cache and outlives the provider — never revoked) and the REVOCABLE
* private-temp grant per session (revoked on provider dispose).
*/
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
private readonly tempGrants = new Map<string, AclWriteGrant>()
/** Session id → the private temp directory this provider created (removed on dispose). */
private readonly tempDirs = new Map<string, string>()
constructor(ctx: Context, config: Config) {
super(ctx)
@@ -208,6 +305,13 @@ export class LocalSandboxProvider extends SandboxProvider {
this.configuredRunnerFailureSignatures = runnerFailureSignatures
this.probeTimeoutMs = config.probeTimeoutMs as number
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
// The temp grants are revoked with the provider: a clean server
// shutdown leaves no temp ACEs behind (workspace ACEs stand by design —
// the reuse cache; an unclean shutdown leaves them for the next
// provision's exact-ACE skip).
ctx.effect(() => () => {
this.revokeAclGrants()
})
}
/**
@@ -246,10 +350,154 @@ export class LocalSandboxProvider extends SandboxProvider {
case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
case 'windows-acl': return this.windowsAclRunnerArgv(policy)
default: return assertNever(runner)
}
}
/**
* The windows-acl runner argv for one policy. With a calling session (the
* policy's `sessionId`), the write grants are materialized once per server
* lifetime — the standing workspace-root grant per workspace and the
* revocable private-temp grant per session — and the runner receives
* `--write-sid` (the workspace-derived identity; its presence marks the
* seam-managed DACL contract) plus, under workspace-write, the session's
* PRIVATE temp subdirectory (derived from session id + workspace) — it
* grants nothing and revokes nothing. Agentless calls pass the ambient
* temp root and no `--write-sid`: the runner self-manages its DACLs.
* @param policy - the resolved per-call policy.
* @returns the runner invocation.
*/
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
const sessionId = policy.sessionId
if (sessionId === undefined) {
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
'--temp', tmpdir(),
'--mode', policy.mode,
]
}
this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode)
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
// Workspace-write sessions confine their temp writes to the PRIVATE
// per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only
// runs pass the ambient temp root — the runner validates it exists
// but grants nothing. The derived write SID is the per-workspace
// identity; the flag's presence marks the seam-managed DACL contract.
'--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(),
'--mode', policy.mode,
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
]
}
/**
* Materialize the session's ACEs once per server lifetime: lazily at its
* first confined execution, reused for every later call (the map hits are
* the whole call). The write SID is the per-workspace identity derived
* from the workspace. Workspace-write grants the workspace root STANDING
* (the ACE outlives every session — the reuse cache) and the session's
* private temp subdirectory REVOCABLY — the directory is derived from
* session id + workspace, created here EXCLUSIVELY (a pre-existing entry
* or a reparse point fails the first confined run loudly, so the grant
* never lands on a foreign object); read-only materializes NOTHING — its
* token alone restricts every write, and the standing grant from an
* earlier workspace-write period is KEPT through a downgrade (never
* revoked): the read-only restricted token carries no write SID (the
* read-only list), so the ACE is inert there, while the map hit keeps the
* re-upgrade free of re-propagation. Fail-closed: a half-materialized
* temp grant is revoked before the error propagates.
* @param sessionId - the policy's calling-session identity.
* @param workspaceRoot - the resolved policy root.
* @param mode - the policy mode (grants exist only under workspace-write).
*/
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void {
if (mode === 'read-only') return
const writeSid = workspaceWriteSid(workspaceRoot)
const tempDir = sessionTempDir(sessionId, workspaceRoot)
if (!this.workspaceGrants.has(workspaceRoot)) {
const grant = AclWriteGrant.create(writeSid)
try {
grant.add(workspaceRoot, true)
} catch (error) {
// Free the SID; a standing ACE (if the apply succeeded before a
// post-apply throw) is the intended end state, not an error
// artifact — nothing to revoke.
try {
grant.dispose()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
}
throw error
}
this.workspaceGrants.set(workspaceRoot, grant)
}
if (this.tempGrants.has(sessionId)) return
const grant = AclWriteGrant.create(writeSid)
// The directory is removed again in the catch only when THIS confine
// created it — a pre-existing entry (EEXIST) is a foreign object and is
// never deleted.
let created = false
try {
// Exclusive creation (no `recursive`): a pre-existing entry OR a
// reparse point both fail EEXIST — the grant never lands on a foreign
// object.
mkdirSync(tempDir)
created = true
grant.add(tempDir)
} catch (error) {
if (created) rmSync(tempDir, { recursive: true, force: true })
// Revoke whatever stands and free the SID — never leave a half-grant
// behind a failed confine (the runner never runs).
try {
grant.dispose()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
}
throw error
}
this.tempGrants.set(sessionId, grant)
this.tempDirs.set(sessionId, tempDir)
}
/**
* Dispose every write grant (provider dispose): the revocable temp ACEs
* are revoked, the private temp directories this provider created are
* removed, and every SID allocation is freed; the standing workspace ACEs
* stay (the reuse cache). Cleanup failures are reported, not thrown:
* cordis teardown must not be aborted by grant cleanup. A crash skips all
* of it — the next resume then fails loudly at the exclusive creation and
* OS temp hygiene (or manual removal) recovers.
*/
private revokeAclGrants(): void {
if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return
const failures: unknown[] = []
for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) {
try {
grant.dispose()
} catch (error) {
failures.push(error)
}
}
const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) })
for (const dir of this.tempDirs.values()) {
try {
rmTempDir(dir)
} catch (error) {
failures.push(error)
}
}
this.workspaceGrants.clear()
this.tempGrants.clear()
this.tempDirs.clear()
if (failures.length > 0) {
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
for (const error of failures) this.ctx.logger.warn(error)
}
}
/**
* Resolve which runner confines commands, once, for the provider's
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
@@ -296,6 +544,11 @@ export class LocalSandboxProvider extends SandboxProvider {
const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs))
return probe(this.seatbeltExec()) ? 'full' : 'unusable'
}
case 'windows-acl': {
const probe = this.internals.probeWindowsAcl
?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))
return probe() ? 'full' : 'unusable'
}
default: return assertNever(runner)
}
}
@@ -309,6 +562,21 @@ export class LocalSandboxProvider extends SandboxProvider {
private seatbeltExec(): string {
return this.internals.seatbeltExec ?? 'sandbox-exec'
}
/**
* The windows-acl runner argv prefix: the built lib/runner.js entry when
* present (production), else the package source through tsx (development).
* The prefix stays `[node, runner, ...]` — a future native-exe runner keeps
* the same argv contract and only swaps these entries.
*/
private windowsAclRunnerInvocation(): string[] {
const override = this.internals.windowsAclRunnerArgs
if (override !== undefined) return override
const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner'))
if (existsSync(builtEntry)) return [process.execPath, builtEntry]
const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts'))
return [process.execPath, '--import', 'tsx/esm', sourceEntry]
}
}
export default LocalSandboxProvider

View File

@@ -0,0 +1,404 @@
/**
* windows-acl write grants: the SERVER-LIFETIME ACE materialization
* (standing workspace grant per workspace, revocable private-temp grant per
* session) plus the derived private-temp identity, through the REAL
* LocalSandboxProvider.confine(). Win32 surface mocked at the package
* boundary (the workspace-derived SID mocked to a constant); the real-FFI
* grant behavior lives in sandbox-windows-acl's win32 tests.
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local'
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
const mockState = vi.hoisted(() => ({
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
addFailure: undefined as Error | undefined,
/** Restricts {@link addFailure} to this path (undefined = every add throws). */
addFailurePath: undefined as string | undefined,
disposeFailure: undefined as Error | undefined,
}))
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
class MockAclWriteGrant {
readonly writeSid: string
readonly added: Array<{ path: string; standing: boolean }> = []
disposed = false
constructor(writeSid: string) {
this.writeSid = writeSid
mockState.grants.push(this)
}
static create(writeSid: string): MockAclWriteGrant {
return new MockAclWriteGrant(writeSid)
}
add(path: string, standing = false): void {
if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) {
throw mockState.addFailure
}
this.added.push({ path, standing })
}
dispose(): void {
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
this.disposed = true
}
}
return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' }
})
/** The workspace-derived write SID the mock pins for every workspace. */
const DERIVED_SID = 'S-1-4-42-42'
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }
return { ctx, sandbox, fiber }
}
/** A workspace root the policy carries. */
function workspaceRoot(): string {
return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-'))
}
describe('windows-acl write grants (LocalSandboxProvider)', () => {
const scratch: string[] = []
beforeEach(() => {
mockState.grants = []
mockState.addFailure = undefined
mockState.addFailurePath = undefined
mockState.disposeFailure = undefined
})
const cleanup = () => {
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
}
it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => {
try {
const { sandbox, fiber } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
const tempDir = sessionTempDir(SessionId('sess-1'), ws)
scratch.push(tempDir)
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
expect(confined.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tempDir,
'--mode', 'workspace-write',
'--write-sid', DERIVED_SID,
'--',
'pwsh', '/Command', 'x',
])
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]).toMatchObject({
writeSid: DERIVED_SID,
added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked
disposed: false,
})
expect(mockState.grants[1]).toMatchObject({
writeSid: DERIVED_SID,
added: [{ path: tempDir, standing: false }],
disposed: false,
})
expect(existsSync(tempDir)).toBe(true) // created exclusively
// Reuse: the second confine is the map hits.
sandbox.confine(['pwsh', '/Command', 'x'], policy)
expect(mockState.grants).toHaveLength(2)
await fiber.dispose()
// dispose() runs on BOTH grants: the standing workspace ACE is left in
// place (the mock marks it disposed only as instance teardown).
expect(mockState.grants[0]!.disposed).toBe(true)
expect(mockState.grants[1]!.disposed).toBe(true)
} finally {
cleanup()
}
})
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => {
try {
const { sandbox } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
const tempDir = sessionTempDir(SessionId('sess-switch'), ws)
scratch.push(tempDir)
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
// read-only first: nothing materialized, ambient temp.
const confinedRo = sandbox.confine(['true'], readOnly)
expect(confinedRo.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
'--mode', 'read-only',
'--write-sid', DERIVED_SID,
'--',
'true',
])
expect(mockState.grants).toHaveLength(0)
expect(existsSync(tempDir)).toBe(false)
// Upgrade: first workspace-write materializes with the derived SID.
const upgraded = sandbox.confine(['true'], workspaceWrite)
expect(upgraded.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tempDir,
'--mode', 'workspace-write',
'--write-sid', DERIVED_SID,
'--',
'true',
])
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false })
expect(mockState.grants[1]).toMatchObject({
writeSid: DERIVED_SID,
added: [{ path: tempDir, standing: false }],
disposed: false,
})
expect(existsSync(tempDir)).toBe(true)
// Reuse: map hits.
sandbox.confine(['true'], workspaceWrite)
expect(mockState.grants).toHaveLength(2)
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
sandbox.confine(['true'], readOnly)
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]!.disposed).toBe(false)
} finally {
cleanup()
}
})
it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => {
try {
const ws = workspaceRoot()
scratch.push(ws)
const first = await setup()
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
const firstConfined = first.sandbox.confine(['true'], policy)
expect(mockState.grants).toHaveLength(2)
// Clean restart: dispose revokes the temp ACE and removes the private
// temp directory, so the fresh provider's exclusive creation succeeds.
await first.fiber.dispose()
mockState.grants = []
const second = await setup()
const secondConfined = second.sandbox.confine(['true'], policy)
expect(secondConfined.argv).toEqual(firstConfined.argv)
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[1]).toMatchObject({
writeSid: DERIVED_SID,
added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }],
})
await second.fiber.dispose()
} finally {
cleanup()
}
})
it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => {
try {
const { sandbox } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') }
const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
sandbox.confine(['true'], parentPolicy)
const parentTemp = sessionTempDir(SessionId('parent'), ws)
scratch.push(parentTemp)
sandbox.confine(['true'], childPolicy)
const childTemp = sessionTempDir(SessionId('child'), ws)
scratch.push(childTemp)
// Fresh temp identity, NOT the parent's (the workspace SID is shared by
// derivation — the workspace is the same, so the standing grant is the
// map hit and only the child's temp grant joins).
expect(childTemp).not.toBe(parentTemp)
expect(mockState.grants).toHaveLength(3)
expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] })
} finally {
cleanup()
}
})
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => {
try {
const { sandbox } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
// Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it.
const preexisting = sessionTempDir(SessionId('preexisting'), ws)
mkdirSync(preexisting)
scratch.push(preexisting)
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
// The standing workspace grant is the intended end state and stays; the
// failed temp grant self-disposes.
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]!.disposed).toBe(false)
expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked
// Reparse point: same EEXIST (exclusive mkdir never follows links).
const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-'))
scratch.push(target)
const linkPath = sessionTempDir(SessionId('reparse'), ws)
symlinkSync(target, linkPath)
scratch.push(linkPath)
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
// Same workspace as the preexisting case: the standing workspace grant
// is the map hit (not recreated) — only the failed temp grant joins.
expect(mockState.grants).toHaveLength(3)
expect(mockState.grants[2]!.disposed).toBe(true)
// Temp-side cleanup failure: the standing workspace grant stays (map
// hit), the exclusive mkdir fails, AND the temp grant's dispose also
// fails — the temp cleanup AggregateError propagates.
mockState.grants = []
mockState.disposeFailure = new Error('temp cleanup exploded')
const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws)
mkdirSync(dupTemp)
scratch.push(dupTemp)
const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') }
expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/)
expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit)
} finally {
cleanup()
}
})
it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
try {
const { sandbox } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws))
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
// add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates.
mockState.addFailure = new Error('grant exploded')
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
expect(mockState.grants).toHaveLength(1)
expect(mockState.grants[0]!.disposed).toBe(true)
// add() AND dispose() both throw: AggregateError.
mockState.grants = []
mockState.addFailure = new Error('grant exploded again')
mockState.disposeFailure = new Error('cleanup exploded')
expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError)
} finally {
cleanup()
}
})
it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => {
try {
const { sandbox } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws)
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') }
// The workspace grant succeeds; only the TEMP grant's add throws (the
// path-targeted failure keeps the workspace branch intact).
mockState.addFailurePath = tempDir
mockState.addFailure = new Error('temp add exploded')
expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded')
expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays
expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes
} finally {
cleanup()
}
})
it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => {
try {
const { sandbox, fiber } = await setup()
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
expect(confined.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', '/ws',
'--temp', tmpdir(),
'--mode', 'workspace-write',
'--',
'pwsh', '/Command', 'x',
])
expect(mockState.grants).toHaveLength(0)
await fiber.dispose()
} finally {
cleanup()
}
})
it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
try {
const { ctx, sandbox, fiber } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
scratch.push(sessionTempDir(SessionId('sess-dispose'), ws))
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
sandbox.confine(['true'], policy)
expect(mockState.grants).toHaveLength(2)
mockState.disposeFailure = new Error('revoke exploded')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
await fiber.dispose()
// BOTH grants (standing workspace + revocable temp) fail their dispose.
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)'))
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
} finally {
cleanup()
}
})
it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
try {
const { ctx, sandbox, fiber } = await setup()
const ws = workspaceRoot()
scratch.push(ws)
scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws))
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') }
sandbox.confine(['true'], policy)
expect(mockState.grants).toHaveLength(2)
sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') }
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
await fiber.dispose()
// Both grants dispose cleanly; only the directory removal fails.
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)'))
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' }))
} finally {
cleanup()
}
})
it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => {
const base = sessionTempDir(SessionId('sess-a'), '/ws/a')
expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/)
expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base)
expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session
expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace
// The separator prevents id/workspace collisions from merging inputs.
expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc'))
})
})

View File

@@ -209,13 +209,10 @@ describe('the platform chains', () => {
expect(probeSeatbelt).not.toHaveBeenCalled()
})
it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => {
// The slot exists so Windows support is an additive fill-in (chain entry
// + runner union member), never a redesign — and reserving it must not
// weaken the fail-closed end in the meantime.
const { sandbox } = await setup({}, { platform: 'win32' })
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
// The win32 chain's argv contract, denial dialect, and runner-failure rules
// live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts
// (platform-independent assertions that run in every CI lane, including
// Windows where this package's POSIX-only suites are excluded).
it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => {
const probeBwrap = vi.fn(() => true)
@@ -368,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
})
describe('the windows-acl probe (runner invocation contract)', () => {
// The product chain reaches windows-acl only unprobed (win32's sole
// candidate), so the probe case and the runner-entry resolution are pinned
// through the chain seam, mirroring the seatbelt default-probe contract.
it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => {
const probeWindowsAcl = vi.fn(() => true)
const { sandbox } = await setup({}, {
chain: ['windows-acl', 'bwrap'],
probeWindowsAcl,
probeBwrap: () => false,
windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
})
const confined = sandbox.confine(['true'], RO)
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
expect(confined.enforcement).toBe('full')
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
})
it('reads a failing probe as unusable and walks to the next rung', async () => {
const probeWindowsAcl = vi.fn(() => false)
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true })
const confined = sandbox.confine(['true'], RO)
expect(confined.argv[0]).toBe('bwrap')
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
})
it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => {
// The default probe spawns the exact runner argv confine would use — the
// runner source through tsx on a lib-less checkout. The windows-acl
// runner cannot init off win32, so the probe reads unusable and the walk
// falls through to the injected bwrap verdict on every host.
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true })
const confined = sandbox.confine(['true'], RO)
expect(confined.argv[0]).toBe('bwrap')
}, 30_000)
it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => {
// windowsAclRunnerInvocation always yields [node, ...] in product; an
// override returning [] exercises the default probe's empty-argv guard.
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] })
const confined = sandbox.confine(['true'], RO)
expect(confined.argv[0]).toBe('bwrap')
})
it('prefers the built lib/runner.js entry when the resolved file exists', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-'))
const builtEntry = join(dir, 'runner.js')
writeFileSync(builtEntry, '')
const { sandbox } = await setup({}, {
chain: ['windows-acl', 'bwrap'],
probeWindowsAcl: () => true,
windowsAclRunnerEntry: builtEntry,
})
const confined = sandbox.confine(['true'], RO)
expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry])
})
})

View File

@@ -28,6 +28,10 @@ const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${proces
/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */
const WORKSPACE_CLOSURE = [
'packages/sandbox/sandbox-local',
// sandbox-local's win32 chain rung is a runtime dependency: a packed
// consumer resolves it like any other @deepseek-ai peer (koffi arrives
// from the registry).
'packages/sandbox/sandbox-windows-acl',
'packages/sandbox/sandbox',
'packages/llm/llm',
'packages/util/brand',

View File

@@ -26,6 +26,12 @@
{
"path": "../sandbox"
},
{
"path": "../sandbox-windows-acl"
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}

View File

@@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service {
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
...session === undefined ? {} : { sessionId: session.id },
}
}

View File

@@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/projects/first'),
sessionId: 'sess-first',
})
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
mode: 'read-only',
workspaceRoot: resolve('/projects/second'),
sessionId: 'sess-second',
})
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
@@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
mode: 'workspace-write',
workspaceRoot: realpathSync.native(physical),
sessionId: 'sess-symlink-parent',
})
} finally {
rmSync(root, { recursive: true, force: true })
@@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
mode: 'danger-full-access',
workspaceRoot: resolve('/projects/approved'),
sessionId: 'sess-approved',
})
})

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
README.md: b13160f7490878143c719ca617936b74ffd298af
README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44

View File

@@ -0,0 +1,91 @@
# @deepseek-ai/dsh-sandbox-windows-acl
English | [中文](README.zh.md)
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary).
Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all).
## Usage
```ts
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
const workspaceRoot = process.cwd()
// mode selects the token's restricting-SID list (see Modes below) and must
// match the grant shape: read-only pairs with zero grants. workspace-write
// REQUIRES the workspace's write SID — the per-workspace identity.
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
const { stdout, stderr, exitCode } = await child.wait()
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
```
A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction.
## The confinement runner
The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract:
```sh
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
```
The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial.
**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host).
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection.
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle.
## Header verification
All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts):
```sh
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
```
The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory.
## Verified boundaries (inherent to restricted tokens, not this port)
- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement.
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls <dir> /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace.
- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation.
- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead.
- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation.
- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected.
## Model Experience
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection.
#### KV Cache effect
None directly; the denial surface belongs to the tool layer.
## Known Limitations and Deferred Work
- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove.
- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them.
- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path.
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this.
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated.
- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage.

View File

@@ -0,0 +1,93 @@
# @deepseek-ai/dsh-sandbox-windows-acl
[English](README.md) | 中文
面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式Linux/macOS 后端在同一包中。
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE保活组登录 SID + Everyone——下文「模式」段是完整边界
直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)[mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACLAppContainer 根本无法任意路径读取)。
## 用法
```ts
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
const workspaceRoot = process.cwd()
// mode selects the token's restricting-SID list (see Modes below) and must
// match the grant shape: read-only pairs with zero grants. workspace-write
// REQUIRES the workspace's write SID — the per-workspace identity.
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
const { stdout, stderr, exitCode } = await child.wait()
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
```
直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)``dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程fail-open。本移植从构造上 fail-closed。
<a id="the-confinement-runner"></a>
## 隔离 runner
面向 seam 的形态是 **runner 入口**`./runner``@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约:
```sh
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
```
runner 创建受限令牌,在它之下 spawn 包装后的 argv调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` jobrunner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
**按工作区授权复用**`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**sha256、16 位 hex而非存储恢复的会话派生同一个目录并重新授权精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID不传它独立使用时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
- `workspace-write`(登录 SID、Everyone、写入 SID工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
- `read-only`(登录 SID、Everyone——**不含**写入 SID**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`因此访问掩码落在其内的打开者cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACLPowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE在两种模式下都被关闭——面向模型的表面记录的是该契约而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API`AclWriteGrant` 是授权生命周期的服务端物化一半。
## 头部验证
所有常量、签名与结构体布局都在开发机上对照 Windows 头文件MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查:
```sh
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
```
koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。
## 已验证边界(受限令牌固有,非本移植引入)
- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。
- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED``0xC0000142`死亡。POC 尝试把控制台登录 SID`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)``ERROR_INVALID_PARAMETER`87失败正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台stdio 重定向走管道,不受影响。
- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE跳过应用写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。
- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir``GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步vitest 实测worker 侧的 `process.env.TMP` 变更从未到达原生块。seam 传入会话的**私有**子目录(`<temp>\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。
- **受限子进程的临时根目录按会话私有**workspace-write + `--write-sid`runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录子进程继承改写后的环境块bwrap `--tmpfs /tmp` 的语义。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。
- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。
## Model Experience
间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
#### KV Cache 影响
无直接影响;拒绝面属于工具层。
## Known Limitations and Deferred Work
- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。
- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID旧路径上的旧 ACE 留在原地(失效、仅含写入 SID。未来的清理命令可以回收它们它们不会引起任何重新传播。
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录罕见——Windows 创建的目录都带真实 DACL意味着「所有人完全控制」`grantWrite` 从该 null 构建新 ACL撤销往返后留下的是 EMPTY全部拒绝DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL因此这仍是记录在案的边界情形而非守护路径。
- **受限孙进程的管道 stdio 捕获不可用named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承`inherit`/fd与忽略`ignore`stdio 的 spawn 可用匿名管道CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACEinit 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
- **宽目录与 FAT 卷警告已推迟FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同它没有安全描述符因此受限令牌的写检查通过Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。
- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage`Add-Type`C# 编译、P/Invoke、非核心 .NET 静态调用(`[System.IO.*]::``[math]::``[Environment]::`、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`「only core types」错误失败`$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型`[string]``[datetime]``[regex]``[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./runner": {
"types": "./lib/types/runner.d.ts",
"default": "./lib/runner.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/runner.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,271 @@
/**
* ACL editing helpers: grant/revoke the orphan write SID on a directory via
* SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
* the failure handling the POC lacks). Every API call is checked and every
* failure is reported with the API name, the exact Win32 code, the formatted
* system text, and the affected path.
*
* Concurrency: grants are read-merge-write against the directory's CURRENT
* DACL, and the whole get-merge-set sequence runs under a per-path exclusive
* LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
* cannot clobber each other's ACEs.
* @module @deepseek-ai/dsh-sandbox-windows-acl/acl
*/
import { createHash } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts'
import type { NativePtr, Win32Bindings } from './ffi.ts'
import * as abi from './win32-abi.ts'
/**
* Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp):
* perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
* MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
* `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
* removes every ACE for the trustee.
* @param sidPtr - the trustee SID the entry names.
* @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS).
* @param permissions - the access mask to grant (0 for REVOKE_ACCESS).
* @returns the packed entry buffer.
*/
export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer {
const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE)
entry.writeUInt32LE(permissions, 0) // grfAccessPermissions
entry.writeUInt32LE(mode, 4) // grfAccessMode
entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI
entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation
entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm
entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID
return entry
}
/**
* One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16
* hex of sha256(lowercased path)>.lock`. The lock root derives from
* GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing
* maps Windows's case-insensitive path spellings onto one lock.
* @param api - the binding table.
* @param path - the protected directory (absolute).
* @returns the lock file path for that directory.
*/
export function lockFilePath(api: Win32Bindings, path: string): string {
const digest = createHash('sha256').update(path.toLowerCase()).digest('hex').slice(0, 16)
return join(getTempPath(api), 'dsh-acl-locks', `${digest}.lock`)
}
/**
* Run `action` holding the per-path exclusive lock: CreateFileW
* (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file
* could be removed and recreated under the holder, letting two processes
* hold "the same" lock), then a one-byte LockFileEx
* (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the
* synchronous handle — see allocOverlapped for why not NULL), then
* UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures
* throw like every other Win32 call in this package; an `action` failure
* still unlocks (best-effort) and rethrows the original error.
* @param api - the binding table.
* @param path - the protected directory (absolute).
* @param action - the get-merge-set sequence to serialize.
* @returns the action's result.
*/
export function withPathLock<T>(api: Win32Bindings, path: string, action: () => T): T {
const lockPath = lockFilePath(api, path)
mkdirSync(dirname(lockPath), { recursive: true })
const handle = api.createFileW(
lockPath,
abi.GENERIC_READ | abi.GENERIC_WRITE,
abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE,
null, abi.OPEN_ALWAYS, 0, null,
)
if (isInvalidHandle(handle)) throwLastError(api, 'CreateFileW', lockPath)
const overlapped = allocOverlapped() // stays zeroed: offset 0, hEvent NULL
if (api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped) === 0) {
const win32Code = api.getLastError()
api.closeHandle(handle) // best-effort on the lock-failure path
throwWin32(api, 'LockFileEx', win32Code, lockPath)
}
let result: T
try {
result = action()
} catch (error) {
// Best-effort release on the action-failure path: cleanup failures must
// not mask the action's error.
api.unlockFileEx(handle, 0, 1, 0, overlapped)
api.closeHandle(handle)
throw error
}
if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) {
const win32Code = api.getLastError()
api.closeHandle(handle) // best-effort on the unlock-failure path
throwWin32(api, 'UnlockFileEx', win32Code, lockPath)
}
if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', `lock file ${lockPath}`)
return result
}
/**
* Read the directory's current explicit DACL via GetNamedSecurityInfoW.
* Allocation contract (the POC's RevokeAccess, minus its missing checks): the
* returned ACL pointer sits INSIDE the security descriptor allocation — only
* the descriptor may be LocalFree'd, and it must not be freed before
* SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself
* corrupts the heap (verified the hard way).
* @param api - the binding table.
* @param path - the directory whose DACL is read.
* @returns the current explicit DACL (null when the directory carries none) and its owning descriptor.
*/
function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } {
const ownerSlot = allocPtrSlot()
const groupSlot = allocPtrSlot()
const daclSlot = allocPtrSlot()
const saclSlot = allocPtrSlot()
const descriptorSlot = allocPtrSlot()
const readResult = api.getNamedSecurityInfoW(
path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION,
ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot,
)
if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path)
return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) }
}
/**
* Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl`
* (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch),
* free the descriptor before applying the merged ACL, apply it, then free the
* merged ACL — checking every call and reporting with the caller's label.
* @param api - the binding table.
* @param path - the directory the DACL edit applies to.
* @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke).
* @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}).
* @param descriptor - the descriptor allocation owning `oldAcl`.
* @param label - the caller's name for error details.
*/
function mergeAndApply(
api: Win32Bindings,
path: string,
entry: Buffer,
oldAcl: NativePtr | null,
descriptor: NativePtr | null,
label: string,
): void {
const newAclSlot = allocPtrSlot()
const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot)
if (mergeResult !== abi.ERROR_SUCCESS) {
if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too
throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`)
}
const newAcl = decodePtr(newAclSlot)
if (newAcl === null) {
if (descriptor !== null) api.localFree(descriptor)
throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`)
}
// The descriptor block (oldAcl included) is dead after the merge — free it
// before applying, exactly like the POC.
const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null
const applyResult = api.setNamedSecurityInfoW(
path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION,
null, null, newAcl, null,
)
const freedNew = api.localFree(newAcl)
if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`)
if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`)
}
/**
* True when the explicit DACL already carries the EXACT write grant this
* module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the
* orphan SID). Every field is read through koffi.decode at pointer offsets —
* no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the
* ACE after the 4-byte mask — there is no pointer to read; reading one
* yields garbage addresses and crashed EqualSid, verified by gdb), so it is
* compared field-by-field against the orphan SID through bounded offset
* reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
* so the caller falls back to the merge-apply path, which owns the robust
* failure handling.
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
* @param sidPtr - the orphan write SID to match.
* @returns whether the exact grant ACE is already present.
*/
function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
const aclSize = decodeUint16At(oldAcl, 2)
const aceCount = decodeUint16At(oldAcl, 4)
if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path
let offset = 8 // the first ACE follows the 8-byte ACL header
for (let index = 0; index < aceCount; index++) {
// ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD);
// ACCESS_ALLOWED_ACE: Mask@4, inline SID@8.
const aceSize = decodeUint16At(oldAcl, offset + 2)
if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path
const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE
&& decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT
&& decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK
if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true
offset += aceSize
}
return false
}
/**
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
* on `path`, inheriting to subcontainers and objects. Idempotent: when the
* directory's current explicit DACL already carries the exact ACE (the
* per-session grant surviving from a previous server lifetime), the
* SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate
* the identical ACE across the whole tree (eager inheritance; minutes on
* large workspaces). Otherwise read-merge-write: the new ACE merges into the
* directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so
* pre-existing explicit ACEs survive. Runs under the per-path lock. The
* directory must be owned by the caller (owner implicit WRITE_DAC) — same
* precondition as the POC.
* @param api - the binding table.
* @param path - the directory whose DACL gains the grant (the workspace or temp root).
* @param sidPtr - the orphan write SID the ACE names.
*/
export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void {
withPathLock(api, path, () => {
const { oldAcl, descriptor } = readCurrentDacl(api, path)
if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) {
// The exact ACE stands: releasing the descriptor is the whole operation.
if (descriptor !== null) {
const freed = api.localFree(descriptor)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`)
}
return
}
mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite')
})
}
/**
* Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
* merge — other entries are preserved). Returns whether an ACE removal was
* attempted (false when the directory carries no DACL at all).
*
* Runs under the per-path lock (the whole get-merge-set sequence); the
* descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
* @param api - the binding table.
* @param path - the directory whose DACL loses the orphan-SID ACEs.
* @param sidPtr - the orphan write SID whose ACEs are removed.
* @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
*/
export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean {
return withPathLock(api, path, () => {
const { oldAcl, descriptor } = readCurrentDacl(api, path)
if (oldAcl === null) {
if (descriptor !== null) {
const freed = api.localFree(descriptor)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`)
}
return false
}
mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite')
return true
})
}

View File

@@ -0,0 +1,21 @@
/**
* Fail-closed Win32 error type. Every backend API failure raises this with the
* API name and the exact Win32 code; the original POC silently ignored every
* failed call and would run children UNRESTRICTED (fail-open) — that is the
* failure mode this class exists to prevent.
* @module @deepseek-ai/dsh-sandbox-windows-acl/errors
*/
export class Win32Error extends Error {
/** The failing Win32 API name, e.g. `CreateRestrictedToken`. */
readonly api: string
/** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */
readonly win32Code: number
constructor(api: string, win32Code: number, detail?: string) {
super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`)
this.name = 'Win32Error'
this.api = api
this.win32Code = win32Code
}
}

Some files were not shown because too many files have changed in this diff Show More