浏览代码

refactor(sandbox): await cancellable preparation in process consumers

Tianyi Cui 6 天之前
父节点
当前提交
caa69608fb
共有 100 个文件被更改,包括 847 次插入388 次删除
  1. 2 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
  2. 8 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  3. 8 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 2 2
      docs/config-catalog.md
  6. 2 2
      docs/config-catalog.zh.md
  7. 2 2
      docs/module-graph.i18n.yaml
  8. 2 1
      docs/module-graph.md
  9. 2 1
      docs/module-graph.zh.md
  10. 2 2
      docs/subsystems/sandbox.i18n.yaml
  11. 5 4
      docs/subsystems/sandbox.md
  12. 5 4
      docs/subsystems/sandbox.zh.md
  13. 2 2
      docs/subsystems/shell.i18n.yaml
  14. 5 5
      docs/subsystems/shell.md
  15. 5 5
      docs/subsystems/shell.zh.md
  16. 2 2
      packages/bundle/headless/README.i18n.yaml
  17. 2 2
      packages/bundle/headless/README.md
  18. 2 2
      packages/bundle/headless/README.zh.md
  19. 4 2
      packages/bundle/headless/package.json
  20. 15 10
      packages/bundle/headless/src/index.ts
  21. 47 0
      packages/bundle/headless/tests/fixtures/provider-cwd.ts
  22. 44 0
      packages/bundle/headless/tests/headless.spec.ts
  23. 3 0
      packages/bundle/headless/tsconfig.json
  24. 2 2
      packages/code-runtime/code-runtime-node/README.i18n.yaml
  25. 1 1
      packages/code-runtime/code-runtime-node/README.md
  26. 1 1
      packages/code-runtime/code-runtime-node/README.zh.md
  27. 3 1
      packages/code-runtime/code-runtime-node/src/index.ts
  28. 29 5
      packages/code-runtime/code-runtime-node/tests/host-failures.spec.ts
  29. 4 4
      packages/code-runtime/code-runtime-node/tests/runtime.spec.ts
  30. 1 1
      packages/context/tmux-context/tests/tmux-context.spec.ts
  31. 6 6
      packages/extensions/tool-cordis/src/api-catalog.ts
  32. 2 2
      packages/fs/fs-local/README.i18n.yaml
  33. 1 1
      packages/fs/fs-local/README.md
  34. 1 1
      packages/fs/fs-local/README.zh.md
  35. 9 5
      packages/fs/fs-local/src/fsio.ts
  36. 2 1
      packages/fs/fs-local/src/index.ts
  37. 27 1
      packages/fs/fs-local/tests/fsio.spec.ts
  38. 1 1
      packages/fs/tool-fs/src/edit.ts
  39. 1 1
      packages/fs/tool-fs/src/read-target.ts
  40. 3 12
      packages/fs/tool-fs/src/session-cwd.ts
  41. 1 1
      packages/fs/tool-fs/src/write.ts
  42. 6 7
      packages/fs/tool-fs/tests/tools.spec.ts
  43. 2 2
      packages/sandbox/sandbox-local/README.i18n.yaml
  44. 1 1
      packages/sandbox/sandbox-local/README.md
  45. 1 1
      packages/sandbox/sandbox-local/README.zh.md
  46. 9 6
      packages/sandbox/sandbox-local/src/index.ts
  47. 41 28
      packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
  48. 11 11
      packages/sandbox/sandbox-local/tests/bwrap.e2e.ts
  49. 8 8
      packages/sandbox/sandbox-local/tests/landlock.e2e.ts
  50. 31 31
      packages/sandbox/sandbox-local/tests/local.spec.ts
  51. 2 2
      packages/sandbox/sandbox-local/tests/packed-install.e2e.ts
  52. 8 8
      packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts
  53. 2 2
      packages/sandbox/sandbox-policy/README.i18n.yaml
  54. 3 3
      packages/sandbox/sandbox-policy/README.md
  55. 3 3
      packages/sandbox/sandbox-policy/README.zh.md
  56. 5 4
      packages/sandbox/sandbox-policy/src/index.ts
  57. 6 6
      packages/sandbox/sandbox-policy/tests/policy.spec.ts
  58. 2 2
      packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts
  59. 2 2
      packages/sandbox/sandbox/README.i18n.yaml
  60. 2 2
      packages/sandbox/sandbox/README.md
  61. 2 2
      packages/sandbox/sandbox/README.zh.md
  62. 2 1
      packages/sandbox/sandbox/src/index.ts
  63. 2 2
      packages/shell/bash-local/README.i18n.yaml
  64. 1 1
      packages/shell/bash-local/README.md
  65. 1 1
      packages/shell/bash-local/README.zh.md
  66. 3 2
      packages/shell/bash-local/src/index.ts
  67. 19 19
      packages/shell/bash-local/tests/executor.spec.ts
  68. 2 2
      packages/shell/bash-sandbox/README.i18n.yaml
  69. 2 2
      packages/shell/bash-sandbox/README.md
  70. 2 2
      packages/shell/bash-sandbox/README.zh.md
  71. 8 7
      packages/shell/bash-sandbox/src/index.ts
  72. 1 1
      packages/shell/bash-sandbox/tests/bwrap.e2e.ts
  73. 44 0
      packages/shell/bash-sandbox/tests/fixtures/async-confinement-failure.ts
  74. 1 1
      packages/shell/bash-sandbox/tests/landlock.e2e.ts
  75. 7 7
      packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
  76. 70 21
      packages/shell/bash-sandbox/tests/sandbox.spec.ts
  77. 1 1
      packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
  78. 2 2
      packages/shell/pwsh-local/README.i18n.yaml
  79. 1 1
      packages/shell/pwsh-local/README.md
  80. 1 1
      packages/shell/pwsh-local/README.zh.md
  81. 3 2
      packages/shell/pwsh-local/src/index.ts
  82. 20 20
      packages/shell/pwsh-local/tests/executor.spec.ts
  83. 2 2
      packages/shell/pwsh-sandbox/README.i18n.yaml
  84. 1 1
      packages/shell/pwsh-sandbox/README.md
  85. 1 1
      packages/shell/pwsh-sandbox/README.zh.md
  86. 7 7
      packages/shell/pwsh-sandbox/src/index.ts
  87. 34 8
      packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
  88. 2 2
      packages/shell/shell/README.i18n.yaml
  89. 3 3
      packages/shell/shell/README.md
  90. 3 3
      packages/shell/shell/README.zh.md
  91. 7 6
      packages/shell/shell/src/index.ts
  92. 2 2
      packages/shell/shell/tests/service.spec.ts
  93. 1 1
      packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
  94. 2 2
      packages/shell/tool-bash/README.i18n.yaml
  95. 3 2
      packages/shell/tool-bash/README.md
  96. 3 2
      packages/shell/tool-bash/README.zh.md
  97. 40 0
      packages/shell/tool-bash/src/background.ts
  98. 9 13
      packages/shell/tool-bash/src/index.ts
  99. 116 0
      packages/shell/tool-bash/tests/background-start.spec.ts
  100. 3 3
      packages/shell/tool-bash/tests/tools.spec.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
-2026-07-06-sandbox.md: 31d96836ad2f932f2abf7d1d76242a711f0de2f6
-2026-07-06-sandbox.zh.md: fdf5f4b1691b4a55fffbd206847c99307e12c9da
+2026-07-06-sandbox.md: 8571a9477fc8f7691daab13d9d533726cea53b43
+2026-07-06-sandbox.zh.md: 403cd6c31cdd31515d060f9512988173a9a3cf7f

+ 8 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.md

@@ -68,9 +68,15 @@ The Landlock launcher source and package family live at `native/system`, next to
 
 Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement.
 
+#### Asynchronous preparation
+
+Provider-owned I/O requires asynchronous confinement. `confine(argv, policy, signal?)` returns a promise so a provider can ask an already-running trusted process to prepare an execution while subprocess argv stays literal. Consumers await that preparation and recheck cancellation before launching a payload; a remote provider does not need to execute a mutable helper artifact outside confinement for every command.
+
+`ShellExecutor.start()` publishes a handle only after preparation succeeds. Generic job admission stays synchronous: tool-owned hooks start preparation after job preflight, own its cancellation, and await any late process before settling. This keeps admission failures from starting work and records asynchronous preparation failures as job outcomes.
+
 #### The bash consumer
 
-`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
+`dsh-bash-sandbox` extends `LocalBashExecutor`, awaits `ctx.sandbox` for the exact `['bash', '-c', command]` argv with the execution signal, rechecks cancellation, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start reject with `SANDBOX_UNAVAILABLE` before handle publication, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
 
 The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
 
@@ -184,7 +190,7 @@ Costs and accepted limits:
 ## FAQ
 
 - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
-- **How is a BROKEN sandbox told apart from a failing command?** An asynchronous subprocess-provider rejection exposes no public execution stage. It identifies a broken confinement runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]; a bare `syscall: 'spawn'` without an exact error path and all other rejections remain stage-neutral provider failures. After a direct outcome exists, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground runner failures throw structured `SANDBOX_UNAVAILABLE` with executable or matched-line detail; an attributable asynchronous background rejection or a matched settled failure stamps `sandbox.runnerFailed`, while every asynchronous rejection renders the local executor's provider-failure note. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
+- **How is a BROKEN sandbox told apart from a failing command?** An asynchronous subprocess-provider rejection exposes no public execution stage. It identifies a broken confinement runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]; a bare `syscall: 'spawn'` without an exact error path and all other rejections remain stage-neutral provider failures. After a direct outcome exists, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground runner failures throw structured `SANDBOX_UNAVAILABLE` with executable or matched-line detail; an attributable asynchronous background rejection or a matched settled failure stamps `sandbox.runnerFailed`, while every asynchronous rejection renders the local executor's provider-failure note. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start reject with the structured error before handle publication; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
 - **What happens on a platform with no backend?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE`, and the command never spawns.
 - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime.
 - **Does the sandbox restrict network or process visibility?** `SandboxMode` claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host `/proc/<pid>` magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged ([decision](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)). Whether network restriction becomes its own knob is left open in § The seam.

+ 8 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md

@@ -68,9 +68,15 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
 
 后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。
 
+#### 异步准备
+
+提供方拥有的 I/O 要求异步约束。`confine(argv, policy, signal?)` 返回 Promise,使提供方可以请求已运行的受信进程准备执行,同时让 subprocess argv 保持字面含义。消费方等待准备完成,并在启动载荷前再次检查取消状态;远端提供方无需为每条命令在约束之外执行可变的 helper 产物。
+
+`ShellExecutor.start()` 仅在准备成功后发布句柄。通用任务准入保持同步:工具拥有的钩子在任务预检通过后开始准备,拥有准备阶段的取消,并在结算前等待迟到的进程。这使准入失败不会启动工作,并将异步准备失败记录为任务结果。
+
 #### bash 消费方
 
-`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
+`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,携带执行信号等待 `ctx.sandbox` 为精确的 `['bash', '-c', command]` argv 完成限制准备,重新检查取消后直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会在发布句柄前以 `SANDBOX_UNAVAILABLE` 拒绝;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
 
 模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
 
@@ -184,7 +190,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 ## FAQ
 
 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。
-- **如何区分损坏的沙箱与失败的命令?** 异步 subprocess-provider rejection 不公开执行阶段。只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 confinement runner 损坏;没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有 rejection 都保持不声明阶段的 provider failure。direct outcome 已存在后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台 runner failure 会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 executable 或匹配行详情;可归因的异步后台 rejection 或匹配到的已结算失败会盖章 `sandbox.runnerFailed`,而所有异步 rejection 都会渲染本地执行器的 provider-failure 提示。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
+- **如何区分损坏的沙箱与失败的命令?** 异步 subprocess-provider rejection 不公开执行阶段。只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 confinement runner 损坏;没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有 rejection 都保持不声明阶段的 provider failure。direct outcome 已存在后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台 runner failure 会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 executable 或匹配行详情;可归因的异步后台 rejection 或匹配到的已结算失败会盖章 `sandbox.runnerFailed`,而所有异步 rejection 都会渲染本地执行器的 provider-failure 提示。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会在发布句柄前以该结构化错误拒绝;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
 - **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。
 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。
 - **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/<pid>` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: da6b091110a6e4dc7afb8955ea223cdf537c00ea
-config-catalog.zh.md: cc2bd99c13ce37a2d8c13c9abf6930e250c73cf0
+config-catalog.md: 0899b817e6d73826de170b53581cecfb05cec003
+config-catalog.zh.md: f96f0db17698c1c1e4527e1164bf14b6d494f05b

+ 2 - 2
docs/config-catalog.md

@@ -808,7 +808,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts)
+Source: [`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 
@@ -1835,7 +1835,7 @@ export interface Config {
 
 Depends on: [`SandboxMode`](subsystems/sandbox.md)
 
-Source: [`packages/sandbox/sandbox-policy/src/index.ts:70`](../packages/sandbox/sandbox-policy/src/index.ts)
+Source: [`packages/sandbox/sandbox-policy/src/index.ts:71`](../packages/sandbox/sandbox-policy/src/index.ts)
 
 <a id="deepseek-aidsh-sdk-app"></a>
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -810,7 +810,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts)
+来源:[`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 
@@ -1837,7 +1837,7 @@ export interface Config {
 
 依赖:[`SandboxMode`](subsystems/sandbox.zh.md)
 
-来源:[`packages/sandbox/sandbox-policy/src/index.ts:70`](../packages/sandbox/sandbox-policy/src/index.ts)
+来源:[`packages/sandbox/sandbox-policy/src/index.ts:71`](../packages/sandbox/sandbox-policy/src/index.ts)
 
 <a id="deepseek-aidsh-sdk-app"></a>
 

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 551bbe35ea9c619b35792d72ce2e5b0ad7bbc759
-module-graph.zh.md: 4b066743d1caaba781ea97d03fca8293f0692d4a
+module-graph.md: 9a61987bc2c8762abc7547f536ecdd8a30827281
+module-graph.zh.md: ef94db60570618f28377567f010e8e6bc7391a16

+ 2 - 1
docs/module-graph.md

@@ -1001,6 +1001,7 @@ flowchart TD
   pkg_tool_session_query --> pkg_tools
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
+  pkg_headless --> pkg_fs
   pkg_headless --> pkg_llm
   pkg_headless --> pkg_session
   pkg_headless --> pkg_session_query
@@ -1425,7 +1426,7 @@ flowchart TD
 | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
+| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |

+ 2 - 1
docs/module-graph.zh.md

@@ -1003,6 +1003,7 @@ flowchart TD
   pkg_tool_session_query --> pkg_tools
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
+  pkg_headless --> pkg_fs
   pkg_headless --> pkg_llm
   pkg_headless --> pkg_session
   pkg_headless --> pkg_session_query
@@ -1427,7 +1428,7 @@ flowchart TD
 | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
+| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |

+ 2 - 2
docs/subsystems/sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md
-sandbox.md: 7160ac699a21a319c991ae70262a8bf61da86b0c
-sandbox.zh.md: 6d2b10d63a0e180bc0fe39cc47930e66dee72466
+sandbox.md: e21de53b4f96128637e0d87470a41b8158b7e6c3
+sandbox.zh.md: 26c40ce32b42e0721062e3912d974bc7a1701197

+ 5 - 4
docs/subsystems/sandbox.md

@@ -2,7 +2,7 @@
 
 English | [中文](sandbox.zh.md)
 
-The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies Linux bwrap/Landlock, macOS Seatbelt, and the Windows ACL restricted-token backend; [dsh-bash-sandbox](../../packages/shell/bash-sandbox) and [dsh-pwsh-sandbox](../../packages/shell/pwsh-sandbox) consume it. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`.
+The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies Linux bwrap/Landlock, macOS Seatbelt, and the Windows ACL restricted-token backend; [dsh-bash-sandbox](../../packages/shell/bash-sandbox) and [dsh-pwsh-sandbox](../../packages/shell/pwsh-sandbox) consume it.
 
 Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)
 
@@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial'
 
 ## Per-call policy
 
-The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs.
+The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs.
 
 ```ts type-equiv
 /**
@@ -153,7 +153,7 @@ The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operat
 
 ## Provider and fail-closed errors
 
-`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy.
+`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy.
 
 Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md).
 
@@ -180,10 +180,11 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo
  *   `['bash', '-c', command]`.
  * @param policy - the file-effect policy this execution runs under,
  *   carried per call (see {@link SandboxPolicy}).
+ * @param signal - cancellation while the provider resolves the policy and runner.
  * @returns the argv to spawn instead, plus the enforcement completeness
  *   the selected backend achieves for it.
  */
-abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
+abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>
 ```
 
 Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)

+ 5 - 4
docs/subsystems/sandbox.zh.md

@@ -2,7 +2,7 @@
 
 [English](sandbox.md) | 中文
 
-[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock、macOS Seatbelt 与 Windows ACL 受限令牌后端;[dsh-bash-sandbox](../../packages/shell/bash-sandbox) 和 [dsh-pwsh-sandbox](../../packages/shell/pwsh-sandbox) 是其消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。
+[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与配套子进程提供方共享执行环境的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock、macOS Seatbelt 与 Windows ACL 受限令牌后端;[dsh-bash-sandbox](../../packages/shell/bash-sandbox) 和 [dsh-pwsh-sandbox](../../packages/shell/pwsh-sandbox) 是其消费方。
 
 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)
 
@@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial'
 
 ## 逐调用策略
 
-完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识 spawn 出的进程实际运行的目录。
+完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。
 
 ```ts type-equiv
 /**
@@ -153,7 +153,7 @@ interface ConfinedArgv {
 
 ## 提供方与 fail-closed 错误
 
-`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。
+`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。
 
 提供方选择、探测、缓存和后端特定的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.zh.md)所有。
 
@@ -180,10 +180,11 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo
  *   `['bash', '-c', command]`.
  * @param policy - the file-effect policy this execution runs under,
  *   carried per call (see {@link SandboxPolicy}).
+ * @param signal - cancellation while the provider resolves the policy and runner.
  * @returns the argv to spawn instead, plus the enforcement completeness
  *   the selected backend achieves for it.
  */
-abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
+abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>
 ```
 
 Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)

+ 2 - 2
docs/subsystems/shell.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/shell.md
-shell.md: 298544569906ddf62ad0703e25beab19204855d7
-shell.zh.md: d4f25e333d57142115af67cdee4f4eb4c7c460ab
+shell.md: 0c74e1635cd8eaac5536f932bdcf78393028dd3c
+shell.zh.md: 66cff0b9c5aae7cb3b13ac126d9942e4429f206a

+ 5 - 5
docs/subsystems/shell.md

@@ -166,7 +166,7 @@ The `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) i
 
 ## Background processes: `ShellProcess`
 
-`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the underlying process settles and never rejects; a subprocess provider rejection becomes a `killed` process with a stage-neutral error on stderr. Reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
+`start()` resolves with a handle after asynchronous launch preparation; cancellation or preparation failure rejects before publication. The handle has no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the underlying process settles and never rejects; a subprocess provider rejection becomes a `killed` process with a stage-neutral error on stderr. Reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
 
 ```ts type-equiv
 /**
@@ -240,7 +240,7 @@ Abstract bash execution service. Subclass, implement the abstract methods, and l
 Implementations must honor these semantics:
 
 - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
-- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.
+- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
 - ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
 - A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
 
@@ -262,11 +262,11 @@ abstract resolve(request: ShellExecRequest): ShellExecSpec
 abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
 
 /**
- * Start a background process and return its handle immediately.
+ * Prepare a background process asynchronously and publish its live handle.
  * @param spec - a resolved spec from {@link resolve}, never a raw request.
- * @returns the live process handle (reads, kill, quiescence promise).
+ * @returns the live process handle after preparation; cancellation or setup failure rejects.
  */
-abstract start(spec: ShellExecSpec): ShellProcess
+abstract start(spec: ShellExecSpec): Promise<ShellProcess>
 ```
 
 Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)

+ 5 - 5
docs/subsystems/shell.zh.md

@@ -166,7 +166,7 @@ interface ShellSandboxInfo {
 
 ## 后台进程:`ShellProcess`
 
-`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 会在底层进程结算时完成且绝不 reject;subprocess 提供方的 rejection 会生成状态为 `killed` 的进程,并把不声明阶段的错误写入 stderr。进程结算后仍可读取,并且沙箱事实会在 `done` 完成前写入。
+`start()` 在异步启动准备完成后返回句柄;取消或准备失败会在发布前拒绝调用。该句柄没有 id 或 owner。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 会在底层进程结算时完成且绝不 reject;subprocess 提供方的 rejection 会生成状态为 `killed` 的进程,并把不声明阶段的错误写入 stderr。进程结算后仍可读取,并且沙箱事实会在 `done` 完成前写入。
 
 ```ts type-equiv
 /**
@@ -240,7 +240,7 @@ Abstract bash execution service. Subclass, implement the abstract methods, and l
 Implementations must honor these semantics:
 
 - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
-- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.
+- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
 - ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
 - A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
 
@@ -262,11 +262,11 @@ abstract resolve(request: ShellExecRequest): ShellExecSpec
 abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
 
 /**
- * Start a background process and return its handle immediately.
+ * Prepare a background process asynchronously and publish its live handle.
  * @param spec - a resolved spec from {@link resolve}, never a raw request.
- * @returns the live process handle (reads, kill, quiescence promise).
+ * @returns the live process handle after preparation; cancellation or setup failure rejects.
  */
-abstract start(spec: ShellExecSpec): ShellProcess
+abstract start(spec: ShellExecSpec): Promise<ShellProcess>
 ```
 
 Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)

+ 2 - 2
packages/bundle/headless/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
-README.md: 859b39ed3bc14d7d9f85db179bcbe712918c4171
-README.zh.md: 550036e7fa0650781628e709b3429cc005b7ac66
+README.md: 54ad7a9384f6e6d9e0e987e860816577c6976388
+README.zh.md: a3fc5e2cc831090598c5bcb61cee41f9a17f6538

+ 2 - 2
packages/bundle/headless/README.md

@@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Choosing the session identity
 
-Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
+Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
 
 ### Machine-readable output
 
@@ -81,7 +81,7 @@ The runner awaits the complete application (`ctx.get('loader')?.await()`) so the
 
 ### Patch surface over base
 
-The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument and the `--session-id`/`--json` options, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task and run options from lazy config.
+The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's code runtime as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument and the `--session-id`/`--json` options, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task and run options from lazy config.
 
 ### Exit mapping
 

+ 2 - 2
packages/bundle/headless/README.zh.md

@@ -51,7 +51,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 ### 机器可读输出
 
@@ -81,7 +81,7 @@ runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组
 
 ### 基于 base 的 patch 内容
 
-patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,把 PTC mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数与 `--session-id`/`--json` 选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务与运行选项。
+patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,把 PTC mode 的代码运行时作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数与 `--session-id`/`--json` 选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务与运行选项。
 
 ### 退出映射
 

+ 4 - 2
packages/bundle/headless/package.json

@@ -54,7 +54,8 @@
     "@deepseek-ai/dsh-agent-default-model": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-query": "workspace:^"
+    "@deepseek-ai/dsh-session-query": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -65,6 +66,7 @@
     "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-query": "workspace:^"
+    "@deepseek-ai/dsh-session-query": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^"
   }
 }

+ 15 - 10
packages/bundle/headless/src/index.ts

@@ -17,6 +17,7 @@ import { brandString } from '@deepseek-ai/dsh-brand'
 import { installModelSelection } from '@deepseek-ai/dsh-agent'
 import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-default-model'
+import type {} from '@deepseek-ai/dsh-fs'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { assertNever } from '@deepseek-ai/dsh-util-values'
 import { SessionSeq } from '@deepseek-ai/dsh-session'
@@ -207,7 +208,7 @@ function currentPreset(header: AdoptableHeader, events: Iterable<SessionEvent>,
 }
 
 /** Reject a Session the one-shot runner must not adopt. */
-function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId): void {
+function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId, cwd: string): void {
   const preset = currentPreset(header, events, sessionId)
   if (preset !== undefined) {
     // This bundle composes no preset roster, so resuming the session here would
@@ -223,8 +224,8 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
   if (header.cwd === undefined) {
     throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`)
   }
-  if (header.cwd !== process.cwd()) {
-    throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${process.cwd()}"`)
+  if (header.cwd !== cwd) {
+    throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${cwd}"`)
   }
 }
 
@@ -238,6 +239,7 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
  * @param sessionId - exact Session identity to adopt.
  * @param agentOptions - provider/model pair for this run.
  * @param setup - per-Agent scope setup installing the model selection.
+ * @param cwd - working directory resolved in the mounted filesystem.
  * @returns the resumed Agent.
  */
 async function resolveAgent(
@@ -246,6 +248,7 @@ async function resolveAgent(
   sessionId: SessionId,
   agentOptions: { provider: string; model: string },
   setup: (agentCtx: Context) => void,
+  cwd: string,
 ): Promise<Agent> {
   // Resuming promises the caller a log a later process can continue. Without a
   // durable log the run would succeed, print the id, and still lose the whole
@@ -267,17 +270,17 @@ async function resolveAgent(
     // The runner cannot claim an exclusive interval over an Agent it did not
     // create, so it refuses the identity; the adoptability rules run first so a
     // real mismatch is named instead of the generic refusal.
-    assertAdoptable(live.session.header, liveEvents(live.session), sessionId)
+    assertAdoptable(live.session.header, liveEvents(live.session), sessionId, cwd)
     throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`)
   }
   try {
     using observation = await query.observeSession(sessionId)
-    assertAdoptable(observation.header, observation.events, sessionId)
+    assertAdoptable(observation.header, observation.events, sessionId, cwd)
     const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
     // The observation is a snapshot: another writer may have appended a preset
     // selection before this process took the write lease. Re-check the log
     // resume actually attached, now that no other process can append.
-    assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId)
+    assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd)
     return agent
   } catch (error: unknown) {
     if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
@@ -337,23 +340,25 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise<void>
     installModelSelection(agentCtx, selected)
   }
   const sessionId = brandString<SessionId>(config.sessionId ?? `session-${randomUUID()}`)
+  const fs = ctx.get('fs')
+  const cwd = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.'))
   const agent = config.sessionId === undefined
     ? (await agents.create({
       sessionId,
-      meta: { cwd: process.cwd() },
+      meta: { cwd },
       agentOptions,
       setup,
     })).agent
-    : await resolveAgent(ctx, agents, sessionId, agentOptions, setup)
+    : await resolveAgent(ctx, agents, sessionId, agentOptions, setup, cwd)
   await agent.whenIdle()
   if (config.sessionId !== undefined) {
     // The resume-time check read a snapshot; an overlay can still append a
     // preset selection between it and the interval this run now owns, so
     // re-read the log the runner holds before submitting the task.
-    assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId)
+    assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd)
   }
   const firstSeq = agent.session.seq
-  const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout) : undefined
+  const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout, { cwd }) : undefined
   const stopReasoning = projection === undefined ? streamReasoning(ctx, agent, io.stderr) : undefined
   try {
     try {

+ 47 - 0
packages/bundle/headless/tests/fixtures/provider-cwd.ts

@@ -0,0 +1,47 @@
+/** Empty POSIX filesystem fixture whose execution coordinates differ from the Harness host. */
+import { posix } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { FileSystem, FsError, FsTargetKey, FsVersion, type FsTarget, type FsInfo } from '@deepseek-ai/dsh-fs'
+import schema from '@deepseek-ai/schemastery'
+
+/** Deployment coordinates for the deterministic provider. */
+interface Config { root: string }
+
+/** Empty provider-owned workspace; no operation accesses the host filesystem. */
+export default class ProviderCwdFileSystem extends FileSystem {
+  static Config: schema<Config> = schema.object({ root: schema.string().required() })
+  constructor(ctx: Context, private readonly config: Config) { super(ctx) }
+
+  override get sandboxMode(): 'read-only' { return 'read-only' }
+  override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
+    opts?.signal?.throwIfAborted()
+    const absolute = posix.resolve(opts?.cwd ?? this.config.root, path)
+    return { targetKey: FsTargetKey(absolute), displayPath: path }
+  }
+  override processPath(target: FsTarget): string { return String(target.targetKey) }
+  override fileUrl(target: FsTarget): string {
+    const url = new URL('file:///')
+    url.pathname = this.processPath(target)
+    return url.href
+  }
+  override contains(parent: FsTarget, child: FsTarget): boolean {
+    const relative = posix.relative(this.processPath(parent), this.processPath(child))
+    return relative === '' || (relative !== '..' && !relative.startsWith('../'))
+  }
+  override async stat(target: FsTarget): Promise<FsInfo | undefined> {
+    return this.processPath(target) === this.config.root ? { type: 'directory', version: FsVersion('empty-workspace') } : undefined
+  }
+  override async lstat(path: string, opts?: { cwd?: string }): Promise<FsInfo | undefined> {
+    return this.stat(await this.resolve(path, opts))
+  }
+  override async listDir(target: FsTarget): Promise<[]> {
+    if ((await this.stat(target))?.type !== 'directory') throw new FsError('No such provider directory', 'FS_NOT_FOUND')
+    return []
+  }
+  override async readText(): Promise<never> { throw new FsError('No such provider file', 'FS_NOT_FOUND') }
+  override async streamText(): Promise<never> { throw new FsError('No such provider file', 'FS_NOT_FOUND') }
+  override async readBytes(): Promise<never> { throw new FsError('No such provider file', 'FS_NOT_FOUND') }
+  override async readByteRange(): Promise<never> { throw new FsError('No such provider file', 'FS_NOT_FOUND') }
+  override async writeText(): Promise<never> { throw new FsError('Read-only provider fixture', 'FS_SANDBOX_DENIED') }
+  override async editText(): Promise<never> { throw new FsError('Read-only provider fixture', 'FS_SANDBOX_DENIED') }
+}

+ 44 - 0
packages/bundle/headless/tests/headless.spec.ts

@@ -39,6 +39,8 @@ interface ObservationStub {
 
 /** Runner invocation options layered over the scripted Agent factory. */
 interface BenchOptions {
+  /** Provider-resolved cwd, which can differ from the harness process directory. */
+  filesystemCwd?: string
   task?: string
   useStdin?: boolean
   readStdin?: () => Promise<string>
@@ -123,6 +125,13 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
   run(): Promise<{ code: number; out: string; err: string; order: string[] }>
 }> {
   const ctx = new Context()
+  if (options.filesystemCwd !== undefined) {
+    const cwd = options.filesystemCwd
+    ctx.provide('fs', {
+      resolve: async () => ({ targetKey: cwd, displayPath: cwd }),
+      processPath: () => cwd,
+    } as never)
+  }
   let out = ''
   let err = ''
   const order: string[] = []
@@ -215,6 +224,41 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
 }
 
 describe('headless runner', () => {
+  it('records a fresh Session in the filesystem provider working directory', async () => {
+    const cwd = '/remote/workspace'
+    const test = await bench({
+      before(session) { expect(session.header.cwd).toBe(cwd) },
+      afterPrompt(session, message) { appendTurn(session, 1, message, 'remote answer', true) },
+    }, { filesystemCwd: cwd })
+    try { expect(await test.run()).toMatchObject({ code: 0, out: 'remote answer\n' }) }
+    finally { await test.ctx.fiber.dispose() }
+  })
+
+  it('reports the provider cwd in its opening JSON event', async () => {
+    const cwd = '/remote/workspace'
+    const test = await bench({
+      afterPrompt(session, message) { appendTurn(session, 1, message, 'remote answer', true) },
+    }, { filesystemCwd: cwd, json: true })
+    try {
+      const result = await test.run()
+      expect(result.code).toBe(0)
+      expect(JSON.parse(result.out.split('\n')[0] as string)).toMatchObject({ type: 'session', cwd })
+    } finally { await test.ctx.fiber.dispose() }
+  })
+
+  it('resumes against the provider cwd instead of the host launch directory', async () => {
+    const cwd = '/remote/workspace'
+    const test = await bench({
+      afterPrompt(session, message) { appendTurn(session, 1, message, 'remote resumed', true) },
+    }, {
+      filesystemCwd: cwd, sessionId: 'session-exact',
+      observe: async () => ({ header: { cwd, origin: 'user' }, events: [], [Symbol.dispose]() {} }),
+    })
+    test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd } })
+    try { expect(await test.run()).toMatchObject({ code: 0, out: 'remote resumed\n' }) }
+    finally { await test.ctx.fiber.dispose() }
+  })
+
   it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
     const test = await bench({
       before(session) {

+ 3 - 0
packages/bundle/headless/tsconfig.json

@@ -34,6 +34,9 @@
     },
     {
       "path": "../../session-query/session-query"
+    },
+    {
+      "path": "../../fs/fs"
     }
   ]
 }

+ 2 - 2
packages/code-runtime/code-runtime-node/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-node/README.md
-README.md: a5285ae296b4f3380d8fc593f54f12fd68355a7a
-README.zh.md: 762e5912497d1ce3388305655514eb5faa2b4540
+README.md: d312ee329b0245c35ef25c6de9c516c34848f2ab
+README.zh.md: 7488532ec78622b0e6c197c7fea1a6ab9f813c67

+ 1 - 1
packages/code-runtime/code-runtime-node/README.md

@@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
 
 ### Launch and control
 
-The host strips erasable types, resolves the executable and bootstrap in the configured execution world, wraps the argv through `ctx.sandbox`, and spawns through `ctx.subprocess`. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
+The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
 
 Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
 

+ 1 - 1
packages/code-runtime/code-runtime-node/README.zh.md

@@ -85,7 +85,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
 
 ### 启动与控制
 
-Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
+Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
 
 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 

+ 3 - 1
packages/code-runtime/code-runtime-node/src/index.ts

@@ -220,7 +220,9 @@ export class NodeCodeRuntime extends CodeRuntime {
       const packaged = 'pkg' in process && this.config.bootstrapPath === undefined
       const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
       const argv = [executable, ...packaged ? [] : [heapFlag], ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
-      confined = policy.mode === 'danger-full-access' ? undefined : this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode })
+      confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)
+      // oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
+      if (settled) return await result.promise
       if (confined !== undefined) sandbox.enforcement = confined.enforcement
       // Native launchers need executable search and Windows system paths before the child installs its model environment.
       const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)

+ 29 - 5
packages/code-runtime/code-runtime-node/tests/host-failures.spec.ts

@@ -138,21 +138,21 @@ describe('Node runtime host failures', () => {
 
   it('fails a required unavailable sandbox before spawning', async () => {
     const h = await setup({}, 'read-only')
-    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
+    vi.spyOn(h.ctx.sandbox, 'confine').mockRejectedValue(new SandboxUnavailableError('read-only'))
     expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
     expect(h.spawn).not.toHaveBeenCalled()
   })
 
   it('keeps partial enforcement separate from a successful program', async () => {
     const h = await setup({}, 'read-only')
-    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
+    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
     h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
     expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
   })
 
   it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
     const h = await setup({}, 'read-only')
-    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
+    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
     h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
     const result = await h.start()
     expect(result.error).toEqual({ kind: 'exception', message })
@@ -161,7 +161,7 @@ describe('Node runtime host failures', () => {
 
   it('distinguishes fatal sandbox startup output from a program denial', async () => {
     const h = await setup({}, 'read-only')
-    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
+    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
     h.onBoot(() => {
       h.stderr.write('sandbox-fatal: runner could not initialize')
       h.direct.resolve({ exitCode: 1, signal: null })
@@ -211,6 +211,30 @@ describe('Node runtime host failures', () => {
     expect(h.spawn).not.toHaveBeenCalled()
   })
 
+  it('does not launch when confinement resolves after cancellation', async () => {
+    const h = await setup({}, 'read-only')
+    const entered = Promise.withResolvers<AbortSignal>()
+    const response = Promise.withResolvers<ConfinedArgv>()
+    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation((_argv, _policy, signal) => {
+      entered.resolve(signal!)
+      return response.promise
+    })
+    const controller = new AbortController()
+    const pending = h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)
+    try {
+      const signal = await entered.promise
+      expect(signal.aborted).toBe(false)
+      controller.abort('confinement cancelled')
+      expect(signal.aborted).toBe(true)
+      response.resolve(confinement([process.execPath]))
+      expect((await pending).error).toEqual({ kind: 'abort', message: 'confinement cancelled' })
+      expect(h.spawn).not.toHaveBeenCalled()
+    } finally {
+      response.resolve(confinement([process.execPath]))
+      await pending
+    }
+  })
+
   it('reports an early control EOF using the direct process result', async () => {
     const h = await setup()
     h.spawn.mockImplementation(() => {
@@ -422,7 +446,7 @@ describe('Node runtime host failures', () => {
   it.each([true, false])('attributes a failed confined spawn only with runner evidence (%s)', async (runnerFailed) => {
     const h = await setup({}, 'read-only')
     const runner = '/sandbox-runner'
-    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([runner, ...argv]))
+    vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([runner, ...argv]))
     h.onBoot(() => {
       h.direct.reject(Object.assign(new Error('spawn rejected'), runnerFailed ? { code: 'ENOENT', path: runner, syscall: `spawn ${runner}` } : {}))
     })

+ 4 - 4
packages/code-runtime/code-runtime-node/tests/runtime.spec.ts

@@ -15,7 +15,7 @@ const sandboxUsable = await (async () => {
   const probe = new Context()
   try {
     await probe.plugin(Sandbox, {})
-    probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
+    await probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
     return true
   } catch (error: unknown) {
     if (error instanceof SandboxUnavailableError) return false
@@ -255,15 +255,15 @@ describe('Node program process', () => {
     const confine = ctx.sandbox.confine.bind(ctx.sandbox)
     const policy = runtime.resolve({ program: '', bindings: [] }).sandboxPolicy
     if (policy === undefined || policy.mode === 'danger-full-access') throw new Error('expected confined policy')
-    const wrapped = confine([process.execPath, '--version'], { ...policy, mode: policy.mode })
+    const wrapped = await confine([process.execPath, '--version'], { ...policy, mode: policy.mode })
     const original = wrapped.argv[0]
     if (original === undefined) throw new Error('expected sandbox launcher')
     const executable = await ctx.subprocess.resolveExecutable(original)
     const alias = 'ptc-private-sandbox-launcher'
     await symlink(executable, join(root, alias))
     const previousPath = process.env.PATH
-    const substitute = vi.spyOn(ctx.sandbox, 'confine').mockImplementation((argv, selected) => {
-      const result = confine(argv, selected)
+    const substitute = vi.spyOn(ctx.sandbox, 'confine').mockImplementation(async (argv, selected, signal) => {
+      const result = await confine(argv, selected, signal)
       return { ...result, argv: [alias, ...result.argv.slice(1)] }
     })
     try {

+ 1 - 1
packages/context/tmux-context/tests/tmux-context.spec.ts

@@ -71,7 +71,7 @@ class FakeBash extends ShellExecutor {
     if (this.runError) throw this.runError
     return this.result
   }
-  override start(): ShellProcess {
+  override async start(): Promise<ShellProcess> {
     throw new Error('tmux-context must never start a background job')
   }
 }

+ 6 - 6
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1387,9 +1387,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     description: 'Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end.',
     methods: [
       {
-        signature: 'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv',
+        signature: 'abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>',
         description: 'Wrap `argv` so it executes confined under `policy` on this host; the caller spawns the returned argv in place of its own.',
-        parameters: [{ name: 'argv', description: 'the exact argv the caller is about to spawn (program plus arguments), NOT a shell string — a shell-shaped consumer passes `[\'bash\', \'-c\', command]`.' }, { name: 'policy', description: 'the file-effect policy this execution runs under, carried per call (see {@link SandboxPolicy}).' }],
+        parameters: [{ name: 'argv', description: 'the exact argv the caller is about to spawn (program plus arguments), NOT a shell string — a shell-shaped consumer passes `[\'bash\', \'-c\', command]`.' }, { name: 'policy', description: 'the file-effect policy this execution runs under, carried per call (see {@link SandboxPolicy}).' }, { name: 'signal', description: 'cancellation while the provider resolves the policy and runner.' }],
         returns: 'the argv to spawn instead, plus the enforcement completeness the selected backend achieves for it.',
       },
     ],
@@ -2100,7 +2100,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'shell',
     summary: 'Abstract bash execution service.',
-    description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.\n- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.',
+    description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.\n- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.',
     methods: [
       {
         signature: 'abstract resolve(request: ShellExecRequest): ShellExecSpec',
@@ -2115,10 +2115,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject.',
       },
       {
-        signature: 'abstract start(spec: ShellExecSpec): ShellProcess',
-        description: 'Start a background process and return its handle immediately.',
+        signature: 'abstract start(spec: ShellExecSpec): Promise<ShellProcess>',
+        description: 'Prepare a background process asynchronously and publish its live handle.',
         parameters: [{ name: 'spec', description: 'a resolved spec from {@link resolve}, never a raw request.' }],
-        returns: 'the live process handle (reads, kill, quiescence promise).',
+        returns: 'the live process handle after preparation; cancellation or setup failure rejects.',
       },
     ],
   },

+ 2 - 2
packages/fs/fs-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
-README.md: 6b53b8bea81a31d047154232f4fe03899b6909ec
-README.zh.md: 56c834c7e024bf28909d28c1ebfacbb99dc83518
+README.md: 8bf1c73d0ebd1c9af3e48dc18119e9c4817bc5d8
+README.zh.md: 294dd9e275079706ebdf30c00ad466eb8dce6a17

+ 1 - 1
packages/fs/fs-local/README.md

@@ -33,7 +33,7 @@ Choose `fs-local` for ordinary host-file access in a single process. Choose [`fs
 
 ### Minimal configuration
 
-Load the backend with a base directory; relative paths resolve against it, and absolute paths ignore it.
+Load the backend with a base directory; relative paths resolve against it, and absolute paths ignore it. A relative base is anchored to the provider process working directory, and display paths remain absolute. On POSIX, resolution follows filesystem semantics before lexical normalization: `symlink/..` reaches the parent of the link target, including when the final file does not exist yet.
 
 ```yaml
 - name: '@deepseek-ai/dsh-fs-local'

+ 1 - 1
packages/fs/fs-local/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 最小配置
 
-加载后端并给出基准目录;相对路径以它为基准解析,绝对路径忽略它
+用一个基础目录加载后端;相对路径基于它解析,绝对路径则忽略它。相对基础目录以提供方进程工作目录为起点,展示路径始终保持绝对路径。在 POSIX 上,解析先遵循文件系统语义,再进行词法规范化:`symlink/..` 到达链接目标的父目录,即使最终文件尚不存在也如此
 
 ```yaml
 - name: '@deepseek-ai/dsh-fs-local'

+ 9 - 5
packages/fs/fs-local/src/fsio.ts

@@ -6,15 +6,16 @@
  */
 
 import { randomUUID } from 'node:crypto'
-import { createReadStream } from 'node:fs'
-import { chmod, link, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
+import { createReadStream, realpath as realpathCallback } from 'node:fs'
+import { chmod, link, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
 import type { BigIntStats, Dirent, Stats } from 'node:fs'
-import { basename, dirname, join, resolve } from 'node:path'
-import { TextDecoder } from 'node:util'
+import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
+import { TextDecoder, promisify } from 'node:util'
 import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
 import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
 
 const BINARY_SAMPLE_BYTES = 8192
+const realpath = promisify(realpathCallback.native)
 // Bound one non-abortable FileHandle.read so cancellation is observed between chunks.
 const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024
 
@@ -145,7 +146,9 @@ export interface LocalDirEntry {
  */
 export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
   if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
-  const displayPath = resolve(cwd, path)
+  const absoluteCwd = isAbsolute(cwd) ? cwd : `${process.cwd()}${sep}${cwd}`
+  const raw = isAbsolute(path) ? path : `${absoluteCwd}${sep}${path}`
+  const displayPath = /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(raw) ? raw : resolve(cwd, path)
   try {
     // Prefer the file's own realpath (resolves a symlinked file to its target).
     return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
@@ -166,6 +169,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
   while (true) {
     try {
       const realAncestor = await realpath(ancestor)
+      if (missing.includes('..')) throw new FsError(`cannot resolve "${displayPath}": parent traversal crosses a missing directory`, 'FS_NOT_FOUND')
       // On Windows, realpath of a regular file succeeds where POSIX returns
       // ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
       // Stat the ancestor to restore the semantic distinction: a non-directory

+ 2 - 1
packages/fs/fs-local/src/index.ts

@@ -139,7 +139,8 @@ export class LocalFileSystem extends FileSystem {
   override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
     if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
     if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
-    const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
+    const cwd = opts?.cwd ?? this.config.cwd
+    const info = await probeNoFollow(isAbsolute(path) ? path : `${cwd}/${path}`)
     if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
     if (!info) return undefined
     return { version: info.version, type: info.type, size: info.size }

+ 27 - 1
packages/fs/fs-local/tests/fsio.spec.ts

@@ -8,7 +8,7 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { isAbsolute, join, relative } from 'node:path'
 import { createServer } from 'node:net'
 import {
   applyLiteralEdit,
@@ -93,6 +93,32 @@ describe('resolveLocalTarget', () => {
     await expect(resolveLocalTarget(dir, '   ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
   })
 
+  it('anchors a relative cwd before retaining parent traversal in a display path', async () => {
+    await mkdir(join(dir, 'nested'))
+    const cwd = relative(process.cwd(), dir)
+    const target = await resolveLocalTarget(cwd, 'nested/../created.txt')
+    expect(isAbsolute(target.displayPath)).toBe(true)
+    expect(target.targetKey).toBe(join(await realpath(dir), 'created.txt'))
+    expect((await resolveLocalTarget(cwd, target.displayPath)).targetKey).toBe(target.targetKey)
+  })
+
+  it('rejects parent traversal through a missing directory hierarchy', async () => {
+    await expect(resolveLocalTarget(dir, 'missing/deeper/../created.txt')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
+  })
+
+  it.skipIf(process.platform === 'win32')('resolves parent traversal after a symlink in the provider filesystem', async () => {
+    const physical = join(dir, 'physical')
+    await mkdir(join(physical, 'nested'), { recursive: true })
+    await mkdir(join(dir, 'lexical'))
+    await symlink(join(physical, 'nested'), join(dir, 'lexical', 'link'))
+    const cwd = `${join(dir, 'lexical', 'link')}/..`
+    const before = await resolveLocalTarget(cwd, 'created.txt')
+    expect(before.targetKey).toBe(join(await realpath(physical), 'created.txt'))
+    await writeFile(join(physical, 'created.txt'), 'physical')
+    expect((await resolveLocalTarget(dir, 'lexical/link/../created.txt')).targetKey).toBe(before.targetKey)
+    await expect(resolveLocalTarget(dir, 'missing/../created.txt')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
+  })
+
   it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
     // "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
     // the raw Node error must be translated into the FsError taxonomy so the tool

+ 1 - 1
packages/fs/tool-fs/src/edit.ts

@@ -115,7 +115,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void
       // Resolve the per-call sandbox policy (approved mode > session override
       // > backend default, plus the session cwd root) BEFORE anything executes.
       const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec)
-      const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
+      const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot))
       // Single-slot decision: the policy plugin returns { version: vObserved } or
       // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
       // No stat — the bare default never manufactures a version basis. The intent

+ 1 - 1
packages/fs/tool-fs/src/read-target.ts

@@ -21,7 +21,7 @@ export async function resolveRegularReadTarget(
   exec: ToolExecution,
   requestedPath: string,
 ): Promise<{ target: FsTarget; info: FsInfo }> {
-  const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath))
+  const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec))
   const info = await ctx.fs.stat(target, exec.signal)
   if (info === undefined) {
     ctx.emit('fs/observed', target, { kind: 'absent' }, exec)

+ 3 - 12
packages/fs/tool-fs/src/session-cwd.ts

@@ -8,36 +8,27 @@
  */
 
 import type { ToolExecution } from '@deepseek-ai/dsh-tools'
-import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
-
-const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
 
 /**
  * The session workspace cwd for this call, or `undefined` when none applies.
  * @param exec - the tool-execution context; only its optional `agent` is read.
- * @param requestedPath - the path the provider will resolve; parent traversal
- *   makes a symlinked cwd's filesystem identity observable.
  * @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
  */
-export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined {
-  const cwd = exec.agent?.session.header.cwd
-  if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd
-  return canonicalPath(cwd)
+export function sessionCwd(exec: ToolExecution): string | undefined {
+  return exec.agent?.session.header.cwd
 }
 
 /**
  * Resolution options shared by all model-facing filesystem tools.
  * @param exec - the tool-execution context supplying session cwd and cancellation.
- * @param requestedPath - the path the provider will resolve.
  * @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
  * @returns provider resolution options for the current tool call.
  */
 export function sessionResolveOptions(
   exec: ToolExecution,
-  requestedPath: string,
   policyWorkspaceRoot?: string,
 ): { cwd?: string; signal?: AbortSignal } {
-  const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath)
+  const cwd = policyWorkspaceRoot ?? sessionCwd(exec)
   return {
     ...cwd !== undefined ? { cwd } : {},
     signal: exec.signal,

+ 1 - 1
packages/fs/tool-fs/src/write.ts

@@ -108,7 +108,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void
       // > backend default, plus the session cwd root) BEFORE anything executes;
       // an escalating call throws its distinct text on any non-grant.
       const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
-      const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
+      const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot))
       // Single-slot decision: the policy plugin produces createIfAbsent/
       // replaceIfVersion; the bare default is undefined (unconditional). No stat.
       const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)

+ 6 - 7
packages/fs/tool-fs/tests/tools.spec.ts

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
 import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve, sep } from 'node:path'
 import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
@@ -137,12 +137,12 @@ describe('session cwd resolution', () => {
     ? {}
     : { agent: { session: { header: { cwd } } } }
 
-  it('retains ordinary spelling but resolves the cwd before parent traversal', () => {
+  it('preserves cwd spelling so the filesystem provider resolves parent traversal', () => {
     const cwd = process.cwd()
     const throughParent = `${cwd}${sep}..`
-    expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined()
-    expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd)
-    expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent))
+    expect(sessionCwd(execution() as never)).toBeUndefined()
+    expect(sessionCwd(execution(cwd) as never)).toBe(cwd)
+    expect(sessionCwd(execution(throughParent) as never)).toBe(throughParent)
 
     const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-'))
     const physical = join(root, 'physical')
@@ -150,8 +150,7 @@ describe('session cwd resolution', () => {
     try {
       mkdirSync(physical)
       symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir')
-      expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link)
-      expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link))
+      expect(sessionCwd(execution(link) as never)).toBe(link)
     } finally {
       rmSync(root, { recursive: true, force: true })
     }

+ 2 - 2
packages/sandbox/sandbox-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
-README.md: 9e777274c64971a2c7762507e5b354d239330289
-README.zh.md: 0ed93e2fae8ea4b17e7a084c46c0fd9af514cf24
+README.md: 15da8baaa3ab7ce476f5c45c6207657915fd7815
+README.zh.md: 0e55553e1ff18ae6e91563c34eb30f4dab61f45c

+ 1 - 1
packages/sandbox/sandbox-local/README.md

@@ -54,7 +54,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E
 
 ### Failures and recovery
 
-An unsupported platform or an unusable runner fails closed: `confine()` throws `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
+An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
 
 -----
 

+ 1 - 1
packages/sandbox/sandbox-local/README.zh.md

@@ -54,7 +54,7 @@ kind: "package-reference"
 
 ### 失败与恢复
 
-不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 抛出 `SANDBOX_UNAVAILABLE` 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
+不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
 
 -----
 

+ 9 - 6
packages/sandbox/sandbox-local/src/index.ts

@@ -33,7 +33,7 @@ import {
 } from '@deepseek-ai/node-addon-system/landlock-run'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
+import { SandboxProvider, SandboxUnavailableError, canonicalPath } 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, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
@@ -309,27 +309,30 @@ export class LocalSandboxProvider extends SandboxProvider {
    *
    * @param argv - the exact argv the caller is about to spawn.
    * @param policy - the file-effect policy this execution runs under.
+   * @param signal - cancellation before policy resolution or grant creation.
    * @returns the wrapped argv plus the selected backend's enforcement completeness, denial
    *   signatures, and structured runner-failure rules; throws the fail-closed
    *   `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
    */
-  confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
+  async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
+    signal?.throwIfAborted()
+    policy = { ...policy, workspaceRoot: canonicalPath(policy.workspaceRoot) }
     if (this.runnerCommand !== undefined) {
-      return {
+      return Promise.resolve<ConfinedArgv>({
         argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
         enforcement: 'full',
         denialSignatures: DENIAL_SIGNATURES.runnerCommand,
         runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
-      }
+      })
     }
     const selected = this.selectRunner(policy.mode)
     const runnerArgv = this.runnerArgv(selected.runner, policy)
-    return {
+    return Promise.resolve<ConfinedArgv>({
       argv: [...runnerArgv, '--', ...argv],
       enforcement: selected.enforcement,
       denialSignatures: DENIAL_SIGNATURES[selected.runner],
       runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
-    }
+    })
   }
 
   /** The selected rung's runner invocation (program + profile arguments) for one policy. */

+ 41 - 28
packages/sandbox/sandbox-local/tests/acl-grants.spec.ts

@@ -74,7 +74,7 @@ async function setup() {
 }
 
 function workspaceRoot(): string {
-  return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-'))
+  return realpathSync(mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')))
 }
 
 function flag(argv: readonly string[], name: string): string | undefined {
@@ -102,6 +102,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
     for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
   }
 
+  it('does not create grants for a cancelled confinement request', async () => {
+    const { sandbox, fiber } = await setup()
+    try {
+      const ws = workspaceRoot()
+      scratch.push(ws)
+      const reason = new Error('cancel before grant creation')
+      await expect(sandbox.confine(['true'], {
+        mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('cancelled'),
+      }, AbortSignal.abort(reason))).rejects.toBe(reason)
+      expect(mockState.grants).toEqual([])
+    } finally { await fiber.dispose(); cleanup() }
+  })
+
   it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => {
     try {
       const { sandbox, fiber } = await setup()
@@ -109,7 +122,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       scratch.push(ws)
       const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
 
-      const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
+      const confined = await sandbox.confine(['pwsh', '/Command', 'x'], policy)
       const tempDir = flag(confined.argv, '--temp')
       const tempSid = flag(confined.argv, '--temp-write-sid')
       expect(tempDir).toBeDefined()
@@ -132,7 +145,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       ])
       expect(existsSync(tempDir ?? '')).toBe(true)
 
-      expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv)
+      expect((await sandbox.confine(['pwsh', '/Command', 'x'], policy)).argv).toEqual(confined.argv)
       expect(mockState.grants).toHaveLength(2)
 
       await fiber.dispose()
@@ -151,7 +164,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') }
       const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') }
 
-      expect(sandbox.confine(['true'], readOnly).argv).toEqual([
+      expect((await sandbox.confine(['true'], readOnly)).argv).toEqual([
         'node', 'windows-acl-runner.js',
         '--workspace', ws,
         '--temp', tmpdir(),
@@ -161,13 +174,13 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       ])
       expect(mockState.grants).toHaveLength(0)
 
-      const upgraded = sandbox.confine(['true'], workspaceWrite)
+      const upgraded = await sandbox.confine(['true'], workspaceWrite)
       expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
       expect(mockState.grants).toHaveLength(2)
-      sandbox.confine(['true'], readOnly)
+      await sandbox.confine(['true'], readOnly)
       expect(mockState.grants).toHaveLength(2)
       expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
-      expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv)
+      expect((await sandbox.confine(['true'], workspaceWrite)).argv).toEqual(upgraded.argv)
 
       await fiber.dispose()
     } finally {
@@ -181,13 +194,13 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       scratch.push(ws)
       const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
       const first = await setup()
-      const firstConfined = first.sandbox.confine(['true'], policy)
+      const firstConfined = await first.sandbox.confine(['true'], policy)
       const firstTemp = flag(firstConfined.argv, '--temp') ?? ''
 
       // The first provider remains live: model an unclean prior process whose
       // temp directory and ACE survived. A new provider must still proceed.
       const second = await setup()
-      const secondConfined = second.sandbox.confine(['true'], policy)
+      const secondConfined = await second.sandbox.confine(['true'], policy)
       const secondTemp = flag(secondConfined.argv, '--temp') ?? ''
       expect(secondTemp).not.toBe(firstTemp)
       expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid'))
@@ -207,9 +220,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       const wsA = workspaceRoot()
       const wsB = workspaceRoot()
       scratch.push(wsA, wsB)
-      const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') })
-      const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') })
-      const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') })
+      const parent = await sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') })
+      const child = await sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') })
+      const moved = await sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') })
 
       expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp'))
       expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid'))
@@ -229,16 +242,16 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       scratch.push(ws)
       mockState.addFailureStanding = true
       mockState.addFailure = new Error('workspace grant exploded')
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'),
-      })).toThrow('workspace grant exploded')
+      })).rejects.toThrow('workspace grant exploded')
       expect(mockState.grants).toHaveLength(1)
       expect(mockState.grants[0]!.disposed).toBe(true)
 
       mockState.disposeFailure = new Error('workspace cleanup exploded')
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'),
-      })).toThrow(/workspace grant failed and its cleanup also failed/u)
+      })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u)
       expect(mockState.grants).toHaveLength(2)
     } finally {
       cleanup()
@@ -247,9 +260,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
 
   it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => {
     const { sandbox } = await setup()
-    expect(() => sandbox.confine(['true'], {
+    await expect(sandbox.confine(['true'], {
       mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'),
-    })).toThrow(/temp root must be outside the workspace/u)
+    })).rejects.toThrow(/temp root must be outside the workspace/u)
     expect(mockState.grants).toHaveLength(0)
   })
 
@@ -260,17 +273,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       scratch.push(ws)
 
       mockState.createTempFailure = new Error('temp SID creation exploded')
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'),
-      })).toThrow('temp SID creation exploded')
+      })).rejects.toThrow('temp SID creation exploded')
       expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed
 
       mockState.createTempFailure = undefined
       mockState.addFailureStanding = false
       mockState.addFailure = new Error('temp add exploded')
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'),
-      })).toThrow('temp add exploded')
+      })).rejects.toThrow('temp add exploded')
       const failedTempGrant = mockState.grants.at(-1)
       expect(failedTempGrant?.disposed).toBe(true)
       expect(failedTempGrant?.added).toHaveLength(1)
@@ -279,17 +292,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       mockState.addFailureStanding = false
       mockState.addFailure = new Error('temp add exploded')
       sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') }
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'),
-      })).toThrow(/temp grant materialization failed and its cleanup also failed/u)
+      })).rejects.toThrow(/temp grant materialization failed and its cleanup also failed/u)
       delete sandbox.internals.rmTempDir
 
       mockState.addFailureStanding = false
       mockState.addFailure = new Error('temp add exploded')
       mockState.disposeFailure = new Error('temp cleanup exploded')
-      expect(() => sandbox.confine(['true'], {
+      await expect(sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'),
-      })).toThrow(/temp grant materialization failed and its cleanup also failed/u)
+      })).rejects.toThrow(/temp grant materialization failed and its cleanup also failed/u)
     } finally {
       cleanup()
     }
@@ -298,7 +311,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
   it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => {
     try {
       const { sandbox, fiber } = await setup()
-      const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' })
+      const confined = await sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' })
       expect(confined.argv).toEqual([
         'node', 'windows-acl-runner.js',
         '--workspace', '/ws',
@@ -319,7 +332,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
       const { ctx, sandbox, fiber } = await setup()
       const ws = workspaceRoot()
       scratch.push(ws)
-      const confined = sandbox.confine(['true'], {
+      const confined = await sandbox.confine(['true'], {
         mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'),
       })
       const tempDir = flag(confined.argv, '--temp') ?? ''

+ 11 - 11
packages/sandbox/sandbox-local/tests/bwrap.e2e.ts

@@ -44,8 +44,8 @@ async function provider(): Promise<LocalSandboxProvider> {
 }
 
 /** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
-function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
-  const confined = sandbox.confine(['bash', '-c', command], policy)
+async function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
+  const confined = await sandbox.confine(['bash', '-c', command], policy)
   const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
   return { result, confined }
 }
@@ -54,7 +54,7 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
   it('the passing probe selects the bwrap rung naturally — first in the ladder, full enforcement, EROFS dialect', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const confined = sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
+    const confined = await sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
     expect(confined.argv[0]).toBe('bwrap')
     expect(confined.enforcement).toBe('full')
     expect(confined.denialSignatures).toEqual(['read-only file system'])
@@ -63,7 +63,7 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
   it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).not.toBe(0)
     // The wrap's denialSignatures must be what the kernel actually prints.
     expect(result.stderr.toLowerCase()).toContain('read-only file system')
@@ -73,7 +73,7 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
   it('read-only keeps the tree readable/executable and the fresh /dev/null writable', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).toBe(0)
     expect(result.stdout).toBe('dev-ok\n')
   })
@@ -90,12 +90,12 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
       // denied even in a shared namespace (host init is root-owned), so this
       // comparison is the assertion that fails when --unshare-pid is lost.
       const hostPidNamespace = readlinkSync('/proc/self/ns/pid')
-      const visibility = runConfined(sandbox, 'readlink /proc/self/ns/pid', { mode, workspaceRoot: workdir })
+      const visibility = await runConfined(sandbox, 'readlink /proc/self/ns/pid', { mode, workspaceRoot: workdir })
       expect(visibility.result.status).toBe(0)
       expect(visibility.result.stdout.trim()).not.toBe('')
       expect(visibility.result.stdout.trim()).not.toBe(hostPidNamespace)
 
-      const escape = runConfined(
+      const escape = await runConfined(
         sandbox,
         `printf escaped > /proc/1/root${target}`,
         { mode, workspaceRoot: workdir },
@@ -108,7 +108,7 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
   it('keeps descendants observable and controllable inside the private PID namespace', async () => {
     const workdir = await tempDir(homedir())
     const sandbox = await provider()
-    const { result } = runConfined(
+    const { result } = await runConfined(
       sandbox,
       'sleep 30 & child=$!; kill -0 "$child" && kill "$child"; wait "$child"; status=$?; test "$status" -ge 128',
       { mode: 'read-only', workspaceRoot: workdir },
@@ -121,11 +121,11 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
     const outside = await tempDir(homedir())
     const sandbox = await provider()
 
-    const inside = runConfined(sandbox, `printf bwrap-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const inside = await runConfined(sandbox, `printf bwrap-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(inside.result.status).toBe(0)
     expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
 
-    const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const denied = await runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(denied.result.status).not.toBe(0)
     expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
   })
@@ -138,7 +138,7 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
     const target = `/tmp/dsh-bwrap-e2e-ephemeral-${process.pid}.txt`
     tempFiles.push(target)
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, `printf tmp-ok > ${target} && cat ${target}`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, `printf tmp-ok > ${target} && cat ${target}`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(result.status).toBe(0)
     expect(result.stdout).toBe('tmp-ok')
     expect(existsSync(target)).toBe(false)

+ 8 - 8
packages/sandbox/sandbox-local/tests/landlock.e2e.ts

@@ -45,8 +45,8 @@ async function provider(): Promise<LocalSandboxProvider> {
 }
 
 /** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's enforcement. */
-function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
-  const confined = sandbox.confine(['bash', '-c', command], policy)
+async function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
+  const confined = await sandbox.confine(['bash', '-c', command], policy)
   const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
   return { result, enforcement: confined.enforcement }
 }
@@ -55,7 +55,7 @@ describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement throu
   it('read-only denies a write — the file must NOT exist, the wrap reports the probed enforcement', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result, enforcement: wrapped } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
+    const { result, enforcement: wrapped } = await runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).not.toBe(0)
     expect(wrapped).toBe(enforcement)
     expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
@@ -64,7 +64,7 @@ describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement throu
   it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).toBe(0)
     expect(result.stdout).toBe('dev-ok\n')
   })
@@ -76,7 +76,7 @@ describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement throu
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
     const target = `/dev/shm/dsh-landlock-e2e-${process.pid}`
-    const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).not.toBe(0)
     expect(existsSync(target)).toBe(false)
   })
@@ -86,11 +86,11 @@ describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement throu
     const outside = await tempDir(homedir())
     const sandbox = await provider()
 
-    const inside = runConfined(sandbox, `printf landlock-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const inside = await runConfined(sandbox, `printf landlock-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(inside.result.status).toBe(0)
     expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
 
-    const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const denied = await runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(denied.result.status).not.toBe(0)
     expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
   })
@@ -99,7 +99,7 @@ describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement throu
     const workdir = await tempDir(homedir())
     const scratch = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, `printf tmp-ok > ${scratch}/scratch.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, `printf tmp-ok > ${scratch}/scratch.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(result.status).toBe(0)
     expect(readFileSync(join(scratch, 'scratch.txt'), 'utf8')).toBe('tmp-ok')
   })

+ 31 - 31
packages/sandbox/sandbox-local/tests/local.spec.ts

@@ -126,7 +126,7 @@ describe('runnerCommand config', () => {
       runnerCommand: ['fake-runner', '--flag'],
       runnerFailureSignatures: ['fake-runner: profile rejected'],
     }, { probeBwrap, probeLandlock, probeSeatbelt })
-    const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
+    const confined = await sandbox.confine(['bash', '-c', 'echo hi'], WW)
     expect(confined).toEqual({
       argv: ['fake-runner', '--flag', ...bwrapProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
       enforcement: 'full',
@@ -143,7 +143,7 @@ describe('runnerCommand config', () => {
   it('an EMPTY runnerCommand means unconfigured: the platform chain still gates the wrap', async () => {
     const probeBwrap = vi.fn(() => false)
     const { sandbox } = await setup({ runnerCommand: [] }, { platform: 'linux', probeBwrap, probeLandlock: () => 'unusable' })
-    expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(SandboxUnavailableError)
     expect(probeBwrap).toHaveBeenCalledTimes(1)
   })
 
@@ -174,7 +174,7 @@ describe('the platform chains', () => {
     const probeBwrap = vi.fn(() => true)
     const probeLandlock = vi.fn(() => 'full' as const)
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined).toEqual({
       argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
       enforcement: 'full',
@@ -189,7 +189,7 @@ describe('the platform chains', () => {
     const probeLandlock = vi.fn(() => 'full' as const)
     const launcher = fakeLauncher()
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock, landlockLauncher: launcher })
-    const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
+    const confined = await sandbox.confine(['bash', '-c', 'echo hi'], WW)
     expect(confined).toEqual({
       argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
       enforcement: 'full',
@@ -209,7 +209,7 @@ describe('the platform chains', () => {
     // the consumer classify that as a sandbox failure, not a task failure.
     const probeSeatbelt = vi.fn(() => true)
     const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt })
-    const confined = sandbox.confine(['bash', '-c', 'echo hi'], RO)
+    const confined = await sandbox.confine(['bash', '-c', 'echo hi'], RO)
     expect(confined).toEqual({
       argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
       enforcement: 'full',
@@ -224,7 +224,7 @@ describe('the platform chains', () => {
     const probeLandlock = vi.fn(() => 'full' as const)
     const probeSeatbelt = vi.fn(() => true)
     const { sandbox } = await setup({}, { platform: 'freebsd', probeBwrap, probeLandlock, probeSeatbelt })
-    expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
     expect(probeBwrap).not.toHaveBeenCalled()
     expect(probeLandlock).not.toHaveBeenCalled()
     expect(probeSeatbelt).not.toHaveBeenCalled()
@@ -238,8 +238,8 @@ describe('the platform chains', () => {
   it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => {
     const probeBwrap = vi.fn(() => true)
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap })
-    sandbox.confine(['true'], RO)
-    sandbox.confine(['true'], WW)
+    await sandbox.confine(['true'], RO)
+    await sandbox.confine(['true'], WW)
     expect(probeBwrap).toHaveBeenCalledTimes(1)
   })
 
@@ -247,8 +247,8 @@ describe('the platform chains', () => {
     const probeBwrap = vi.fn(() => false)
     const probeLandlock = vi.fn(() => 'unusable' as const)
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
-    expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
-    expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(SandboxUnavailableError)
     expect(probeBwrap).toHaveBeenCalledTimes(1)
     expect(probeLandlock).toHaveBeenCalledTimes(1)
   })
@@ -260,7 +260,7 @@ describe('the platform chains', () => {
     const exec = fakeSeatbeltExec(0)
     const probeBwrap = vi.fn(() => false)
     const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap, seatbeltExec: exec })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv[0]).toBe(exec)
     expect(confined.enforcement).toBe('full')
     expect(probeBwrap).toHaveBeenCalledTimes(1)
@@ -270,7 +270,7 @@ describe('the platform chains', () => {
     // Same convention as the wrap switch below: the union is closed, so a runner added later
     // fails to compile at the probe switch instead of silently selecting without a probe.
     const { sandbox } = await setup({}, { chain: ['chroot', 'bwrap'] as unknown as readonly ['bwrap'] })
-    expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow('unreachable variant')
   })
 
   it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => {
@@ -278,7 +278,7 @@ describe('the platform chains', () => {
     // runner cannot silently use another runner's wrap or denial dialect.
     const { sandbox } = await setup()
     ;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' }
-    expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow('unreachable variant')
   })
 
   it('runs the real default probes on the linux chain when none are injected (usable here or fail closed there)', async () => {
@@ -286,9 +286,9 @@ describe('the platform chains', () => {
     // spawn run on every host: bwrap answers on a Linux box, ENOENT reads as
     // an unusable rung anywhere else — either way the walk is genuine.
     const { sandbox } = await setup({}, { platform: 'linux' })
-    const verdict = (() => {
+    const verdict = await (async () => {
       try {
-        sandbox.confine(['true'], RO)
+        await sandbox.confine(['true'], RO)
         return 'usable'
       } catch (error: unknown) {
         if (error instanceof SandboxUnavailableError) return 'unavailable'
@@ -300,9 +300,9 @@ describe('the platform chains', () => {
 
   it('walks the real platform chain when nothing is injected (usable here or fail closed there)', async () => {
     const { sandbox } = await setup({}, {})
-    const verdict = (() => {
+    const verdict = await (async () => {
       try {
-        sandbox.confine(['true'], RO)
+        await sandbox.confine(['true'], RO)
         return 'usable'
       } catch (error: unknown) {
         if (error instanceof SandboxUnavailableError) return 'unavailable'
@@ -316,13 +316,13 @@ describe('the platform chains', () => {
 describe('the default landlock probe (launcher CLI contract)', () => {
   it('parses a fully-enforced probe report as full enforcement', async () => {
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: fakeLauncher() })
-    expect(sandbox.confine(['true'], RO).enforcement).toBe('full')
+    expect((await sandbox.confine(['true'], RO)).enforcement).toBe('full')
   })
 
   it('parses a partially-enforced (older-ABI) probe report as partial enforcement', async () => {
     const launcher = fakeLauncher('landlock: partially enforced (older ABI)')
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
-    expect(sandbox.confine(['true'], RO).enforcement).toBe('partial')
+    expect((await sandbox.confine(['true'], RO)).enforcement).toBe('partial')
   })
 
   it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
@@ -331,7 +331,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
     const launcher = join(dir, 'landlock-run')
     writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
-    expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
   })
 })
 
@@ -357,13 +357,13 @@ describe('probeTimeoutMs config', () => {
       { probeTimeoutMs: 15_000 },
       { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
     )
-    expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
+    expect((await patient.sandbox.confine(['true'], RO)).enforcement).toBe('full')
 
     const impatient = await setup(
       { probeTimeoutMs: 250 },
       { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
     )
-    expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
+    await expect(impatient.sandbox.confine(['true'], RO)).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
   }, 30_000)
 })
 
@@ -374,7 +374,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
   it('selects the rung when the executable applies the read-only profile and exits 0', async () => {
     const exec = fakeSeatbeltExec(0)
     const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: exec })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined).toEqual({
       argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
       enforcement: 'full',
@@ -385,7 +385,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
 
   it('reads a failing executable as unusable: the chain ends and fails closed', async () => {
     const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: fakeSeatbeltExec(1) })
-    expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
+    await expect(sandbox.confine(['true'], RO)).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
   })
 })
 
@@ -401,7 +401,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
       probeBwrap: () => false,
       windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
     })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
     expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
     expect(confined.enforcement).toBe('partial')
@@ -412,7 +412,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
   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)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv[0]).toBe('bwrap')
     expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
   })
@@ -425,7 +425,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
     // either way — the runner cannot init off win32, so the probe reads
     // unusable and the walk falls through to the injected bwrap verdict.
     const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv[0]).toBe('bwrap')
   }, 30_000)
 
@@ -438,7 +438,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
       probeWindowsAcl: () => true,
       windowsAclRunnerEntry: absentRunnerEntry(),
     })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv.slice(0, 2)).toEqual([process.execPath, '--import'])
     expect(confined.argv[2]).toMatch(/^data:text\/javascript,/)
     expect(confined.argv[3]).toMatch(/runner\.ts$/)
@@ -453,7 +453,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
       probeWindowsAcl: () => true,
       windowsAclRunnerEntry: absentRunnerEntry(),
     })
-    const { argv } = sandbox.confine(['true'], RO)
+    const { argv } = await sandbox.confine(['true'], RO)
     for (const ambientConfig of [undefined, join(cwd, 'absent-ambient-tsconfig.json')]) {
       const result = spawnSync(argv[0]!, argv.slice(1, 4), {
         cwd,
@@ -472,7 +472,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
     // 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)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv[0]).toBe('bwrap')
   })
 
@@ -486,7 +486,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
       probeWindowsAcl: () => true,
       windowsAclRunnerEntry: builtEntry,
     })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry])
   })
 })

+ 2 - 2
packages/sandbox/sandbox-local/tests/packed-install.e2e.ts

@@ -120,7 +120,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
       const out = { launcher, launcherExists: existsSync(launcher), enforcing: probe.status === 0 }
       const workdir = process.argv[2]
       if (out.enforcing) {
-        const confined = sandbox.confine(['bash', '-c', \`echo hi > \${workdir}/denied.txt\`], { mode: 'read-only', workspaceRoot: workdir })
+        const confined = await sandbox.confine(['bash', '-c', \`echo hi > \${workdir}/denied.txt\`], { mode: 'read-only', workspaceRoot: workdir })
         out.wrapArgv0 = confined.argv[0]
         out.enforcement = confined.enforcement
         const run = spawnSync(confined.argv[0], confined.argv.slice(1), { encoding: 'utf8', timeout: 30000 })
@@ -128,7 +128,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
         out.stderrHasDialect = /permission denied/i.test(run.stderr)
       } else {
         try {
-          sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
+          await sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
           out.confineOutcome = 'wrapped'
         } catch (error) {
           out.confineOutcome = error?.code === 'SANDBOX_UNAVAILABLE' ? 'fail-closed' : String(error)

+ 8 - 8
packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts

@@ -44,8 +44,8 @@ async function provider(): Promise<LocalSandboxProvider> {
 }
 
 /** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
-function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
-  const confined = sandbox.confine(['bash', '-c', command], policy)
+async function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
+  const confined = await sandbox.confine(['bash', '-c', command], policy)
   const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
   return { result, confined }
 }
@@ -54,7 +54,7 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
   it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result, confined } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
+    const { result, confined } = await runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).not.toBe(0)
     expect(confined.enforcement).toBe('full')
     // The wrap's denialSignatures must be what the kernel actually prints.
@@ -65,7 +65,7 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
   it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
+    const { result } = await runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
     expect(result.status).toBe(0)
     expect(result.stdout).toBe('dev-ok\n')
   })
@@ -76,7 +76,7 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
     const workdir = await tempDir(tmpdir())
     const sandbox = await provider()
     const target = join(workdir, 'tmp-denied.txt')
-    const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: await tempDir(homedir()) })
+    const { result } = await runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: await tempDir(homedir()) })
     expect(result.status).not.toBe(0)
     expect(existsSync(target)).toBe(false)
   })
@@ -86,11 +86,11 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
     const outside = await tempDir(homedir())
     const sandbox = await provider()
 
-    const inside = runConfined(sandbox, `printf seatbelt-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const inside = await runConfined(sandbox, `printf seatbelt-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(inside.result.status).toBe(0)
     expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
 
-    const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
+    const denied = await runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
     expect(denied.result.status).not.toBe(0)
     expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
   })
@@ -100,7 +100,7 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
     const hostTmp = await tempDir('/tmp')
     const userTmp = await tempDir(tmpdir())
     const sandbox = await provider()
-    const { result } = runConfined(
+    const { result } = await runConfined(
       sandbox,
       `printf tmp-ok > ${hostTmp}/scratch.txt && printf user-tmp-ok > ${userTmp}/scratch.txt`,
       { mode: 'workspace-write', workspaceRoot: workdir },

+ 2 - 2
packages/sandbox/sandbox-policy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
-README.md: c29908937eb2b2cdae4ced5224d441d8fef3df34
-README.zh.md: 0586b8a32f8daf576844c8013c55eb2e0754ee7b
+README.md: a822ca781640f9192e06961993d663c3578a6e41
+README.zh.md: 0c5f5e5f0a533c1d52eaa43debea7c9272702845

+ 3 - 3
packages/sandbox/sandbox-policy/README.md

@@ -69,7 +69,7 @@ This section explains policy resolution, the per-session store, and the model-vi
 
 ### Resolution precedence
 
-`resolve({ session, mode })` returns one complete per-call policy: an approved explicit mode outranks the session's last `sandbox/mode` event, which outranks the deployment default. The session's immutable `cwd` is canonicalized with filesystem semantics before becoming the workspace root, so `symlink/..` agrees with process working-directory resolution; otherwise the configured fallback applies.
+`resolve({ session, mode })` returns one complete per-call policy: an approved explicit mode outranks the session's last `sandbox/mode` event, which outranks the deployment default. The session's immutable `cwd` supplies the workspace root; otherwise the configured fallback applies. Absolute execution-world spelling is preserved. Enforcing providers canonicalize the root on their own filesystem, so remote `symlink/..` paths are never resolved on the Harness host.
 
 ### The per-session store
 
@@ -77,7 +77,7 @@ A runtime switch is one log-only `sandbox/mode` event on the session it applies
 
 ### Model-visible text
 
-The `sandbox:policy` contribution states the mode's capability-neutral file-effect contract and the canonical session workspace under `workspace-write`. It does not enumerate mounted capabilities; tool plugins retain operation-specific denial and escalation guidance, approval policy contributes separately to the same snapshot, and plan guidance remains `dsh-plan-mode`'s system section. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside the closed mode vocabulary.
+The `sandbox:policy` contribution states the mode's capability-neutral file-effect contract and the recorded session workspace under `workspace-write`. It does not enumerate mounted capabilities; tool plugins retain operation-specific denial and escalation guidance, approval policy contributes separately to the same snapshot, and plan guidance remains `dsh-plan-mode`'s system section. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside the closed mode vocabulary.
 
 ### Source map
 
@@ -131,7 +131,7 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr
 
 #### Token effect
 
-One concise durable context message on the first request and each effective policy change; unchanged requests add nothing. `workspace-write` carries only the canonical session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
+One concise durable context message on the first request and each effective policy change; unchanged requests add nothing. `workspace-write` carries only the recorded session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
 
 #### KV Cache effect
 

+ 3 - 3
packages/sandbox/sandbox-policy/README.zh.md

@@ -69,7 +69,7 @@ kind: "package-reference"
 
 ### 解析优先级
 
-`resolve({ session, mode })` 返回一份完整的逐调用策略:已批准的显式模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于部署默认值。会话的不可变 `cwd` 先按文件系统语义规范化,再成为工作区根目录,因此 `symlink/..` 与进程工作目录解析一致;否则使用配置的回退值
+`resolve({ session, mode })` 返回一份完整的逐调用策略:已批准的显式模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于部署默认值。会话的不可变 `cwd` 提供工作区根目录;否则使用配置的回退值。执行环境中的绝对路径写法保持不变。执行限制的提供方在自己的文件系统上规范化根目录,因此远端 `symlink/..` 路径绝不会在 Harness 主机上解析
 
 ### 逐会话存储
 
@@ -77,7 +77,7 @@ kind: "package-reference"
 
 ### 模型可见文本
 
-`sandbox:policy` 贡献说明该模式与具体能力无关的文件操作约定,以及 `workspace-write` 下规范化的会话工作区。它不枚举已挂载能力;工具插件保留特定于操作的拒绝与升权引导,批准策略单独贡献给同一份快照,计划引导仍由 `dsh-plan-mode` 的系统段落管理。可选的 `./invariant` 配套组件会拒绝值超出封闭模式词汇的伪造持久 `sandbox/mode` 事件。
+`sandbox:policy` 贡献说明该模式与具体能力无关的文件操作约定,以及 `workspace-write` 下已记录的会话工作区。它不枚举已挂载能力;工具插件保留特定于操作的拒绝与升权引导,批准策略单独贡献给同一份快照,计划引导仍由 `dsh-plan-mode` 的系统段落管理。可选的 `./invariant` 配套组件会拒绝值超出封闭模式词汇的伪造持久 `sandbox/mode` 事件。
 
 ### 源码地图
 
@@ -131,7 +131,7 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr
 
 #### Token 影响
 
-首次请求和有效策略每次变化时增加一条简洁的持久上下文消息;未变化的请求不增加内容。`workspace-write` 只携带规范化的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
+首次请求和有效策略每次变化时增加一条简洁的持久上下文消息;未变化的请求不增加内容。`workspace-write` 只携带已记录的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
 
 #### KV Cache 影响
 

+ 5 - 4
packages/sandbox/sandbox-policy/src/index.ts

@@ -20,21 +20,22 @@
  * @module @deepseek-ai/dsh-sandbox-policy
  */
 
-import { resolve as resolvePath } from 'node:path'
+import { isAbsolute } from 'node:path'
 import { Context, Service } from '@deepseek-ai/cordis'
 import { z as zod } from 'zod'
 import z from '@deepseek-ai/schemastery'
 import type {} from '@deepseek-ai/dsh-agent'
-import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
 import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts'
 
-/** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */
+/** Preserve execution-world spelling; enforcing providers resolve filesystem identity on their host. */
 function resolveWorkspaceRoot(path: string): string {
-  return resolvePath(canonicalPath(path))
+  if (!isAbsolute(path)) throw new Error('sandbox-policy: workspace root must be an absolute execution-world path')
+  return path
 }
 
 /** Render the policy without claiming which capabilities are mounted. */

+ 6 - 6
packages/sandbox/sandbox-policy/tests/policy.spec.ts

@@ -4,7 +4,7 @@
  * override kit (fold + write path) every enforcing capability reads.
  */
 
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve, sep } from 'node:path'
 import { describe, expect, it } from 'vitest'
@@ -49,10 +49,10 @@ describe('SandboxPolicyService', () => {
     expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd()))
   })
 
-  it('carries a configured mode and resolves the workspace root absolute', async () => {
+  it('preserves an absolute execution-world root without host path normalization', async () => {
     const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws/../ws/./sub' })
     expect(ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
-    expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
+    expect(ctx.sandboxPolicy.workspaceRoot).toBe('/ws/../ws/./sub')
   })
 
   it('resolves the deployment policy for an agentless call', async () => {
@@ -87,7 +87,7 @@ describe('SandboxPolicyService', () => {
     })
   })
 
-  it.skipIf(process.platform === 'win32')('resolves a symlink-sensitive session cwd with POSIX component semantics', async () => {
+  it.skipIf(process.platform === 'win32')('preserves symlink-sensitive session cwd for its enforcing provider', async () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-'))
     try {
       const lexical = join(root, 'lexical')
@@ -102,7 +102,7 @@ describe('SandboxPolicyService', () => {
 
       expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
         mode: 'workspace-write',
-        workspaceRoot: realpathSync.native(physical),
+        workspaceRoot: cwd,
         sessionId: 'sess-symlink-parent',
       })
     } finally {
@@ -159,7 +159,7 @@ describe('sandbox:policy request context', () => {
 
   it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('renders the exact %s policy without a capability inventory', async (mode) => {
     const ctx = await promptMounted({ mode, workspaceRoot: '/fallback' })
-    const workspaceRoot = resolve('/projects/current')
+    const workspaceRoot = '/projects/../projects/current'
     const expected = {
       'read-only': 'Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.',
       'workspace-write': `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(workspaceRoot)}. Some platform temporary areas may also be writable.`,

+ 2 - 2
packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts

@@ -32,7 +32,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
       windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
       probeWindowsAcl,
     })
-    const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW)
+    const confined = await sandbox.confine(['pwsh', '/Command', 'x'], WW)
     expect(confined.argv).toEqual([
       'node', 'windows-acl-runner.js',
       '--workspace', '/ws',
@@ -50,7 +50,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
 
   it('read-only: same runner and contract, read-only mode flag', async () => {
     const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] })
-    const confined = sandbox.confine(['true'], RO)
+    const confined = await sandbox.confine(['true'], RO)
     expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
     expect(confined.enforcement).toBe('partial')
     expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])

+ 2 - 2
packages/sandbox/sandbox/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md
-README.md: f2a424c5701045e09f1148a1c0694b8056efa213
-README.zh.md: 292228db93670109c7b4cca3b77e393e783edc8d
+README.md: 88e873428115eb7a3880253b668a4de5b42d6e6d
+README.zh.md: b0144d12395cf01667ef7c2045a1d5f722936a4d

+ 2 - 2
packages/sandbox/sandbox/README.md

@@ -81,9 +81,9 @@ This section explains the design decisions behind the contract and points at the
 
 ### Design philosophy
 
-- **Same-world by contract.** `ctx.sandbox` wraps argv under a host-path file policy; containers, microVMs, and remote execution replace the surrounding capability seam instead.
+- **One execution world.** The filesystem, subprocess and sandbox providers operate on the same filesystem and kernel. Remote compositions replace all three providers; confinement resolves asynchronously in that world.
 - **Policy rides the call.** `SandboxPolicy` is carried per call, never fixed on the provider: two consumers may confine under different policies at the same instant, and an escalated retry is a new call with a wider policy. Defaulting and resolution are explicit consumer steps.
-- **Fail closed.** `confine()` returns enforcing argv or throws `SandboxUnavailableError`; silent unconfined passthrough is forbidden, and functional probes arbitrate multi-runner chains.
+- **Fail closed.** `confine()` resolves to enforcing argv or rejects with `SandboxUnavailableError`; silent unconfined passthrough is forbidden, and functional probes arbitrate multi-runner chains.
 - **One vocabulary for denial and escalation.** The marker and hint texts and the strictly-wider ladder live here so the bash and fs families cannot drift apart.
 
 ### Source map

+ 2 - 2
packages/sandbox/sandbox/README.zh.md

@@ -81,9 +81,9 @@ kind: "package-reference"
 
 ### 设计理念
 
-- **约定只支持与宿主共享文件系统和内核的限制。** `ctx.sandbox` 在宿主路径文件策略下包装 argv;容器、microVM 与远程执行会替换周边能力 seam
+- **同一执行环境。** 文件系统、子进程与沙箱提供方使用同一文件系统和内核。远端组合替换这三个提供方,并在该环境中异步解析限制
 - **策略随调用传递。** `SandboxPolicy` 逐调用携带,绝不在提供方上固定:两个消费方可以同时按不同策略隔离,获批的升权重试只是用更宽策略发起的新调用。默认与解析是消费方显式步骤。
-- **故障关闭。** `confine()` 返回用于强制执行限制的 argv,或抛出 `SandboxUnavailableError`;绝不允许静默无限制放行,功能探测用于仲裁多 runner 链。
+- **故障关闭。** `confine()` 异步返回用于强制执行限制的 argv,或以 `SandboxUnavailableError` 拒绝;绝不允许静默无限制放行,功能探测用于仲裁多 runner 链。
 - **统一的拒绝与升权词汇。** 标记与提示文本以及严格更宽阶梯都放在这里,使 bash 与 fs 家族不会漂移。
 
 ### 源码地图

+ 2 - 1
packages/sandbox/sandbox/src/index.ts

@@ -169,10 +169,11 @@ export abstract class SandboxProvider extends Service {
    *   `['bash', '-c', command]`.
    * @param policy - the file-effect policy this execution runs under,
    *   carried per call (see {@link SandboxPolicy}).
+   * @param signal - cancellation while the provider resolves the policy and runner.
    * @returns the argv to spawn instead, plus the enforcement completeness
    *   the selected backend achieves for it.
    */
-  abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
+  abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>
 }
 
 export default SandboxProvider

+ 2 - 2
packages/shell/bash-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/bash-local/README.md
-README.md: 462054d435433067355a91103aa0d606cea6521b
-README.zh.md: 138740f22d93ae02865e71dffb1369cbf3ce2a22
+README.md: 1f30e4d027eb3d47614d5612178d951aff61a002
+README.zh.md: 7676f38a11bcf8781965d90e642307fc48bbdc4b

+ 1 - 1
packages/shell/bash-local/README.md

@@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### Background processes
 
-Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
+Await `start` to run a command in the background; it resolves with the prepared process handle and no execution timeout applies. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### Adjusting budgets at runtime

+ 1 - 1
packages/shell/bash-local/README.zh.md

@@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 后台进程
 
-调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+等待 `start` 即可在后台运行命令;它完成准备后返回进程句柄,且不应用执行超时。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### 运行时调整预算

+ 3 - 2
packages/shell/bash-local/src/index.ts

@@ -241,8 +241,8 @@ export class LocalBashExecutor extends ShellExecutor {
     }
   }
 
-  start(spec: ShellExecSpec): ShellProcess {
-    return this.startArgv(spec, ['bash', '-c', spec.command])
+  async start(spec: ShellExecSpec): Promise<ShellProcess> {
+    return Promise.resolve(this.startArgv(spec, ['bash', '-c', spec.command]))
   }
 
   /**
@@ -256,6 +256,7 @@ export class LocalBashExecutor extends ShellExecutor {
    */
   protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
     // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
+    spec.signal?.throwIfAborted()
     const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
     const collected = LocalBashExecutor.collected(running)
 

+ 19 - 19
packages/shell/bash-local/tests/executor.spec.ts

@@ -167,7 +167,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
   it('start returns immediately with a running handle that settles as completed', async () => {
     const { bash } = await setup()
     const before = Date.now()
-    const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
+    const proc = await bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
     expect(Date.now() - before).toBeLessThan(150)
     expect(proc.status).toBe('running')
     await proc.done
@@ -177,7 +177,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('threads stdin and extra env into a background process', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({
+    const proc = await bash.start(bash.resolve({
       command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"',
       stdin: 'bg-stdin\n',
       env: { BG_VAR: 'bg-env' },
@@ -191,7 +191,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
+    const proc = await bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
     const first = await readUntil(proc, 'first\n')
     expect(first).toBe('first\n')
     await proc.done
@@ -204,28 +204,28 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('readOutput marks stderr sections', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
+    const proc = await bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
     await proc.done
     expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
   })
 
   it('readOutput reports stderr-only deltas without a leading newline', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'echo err >&2' }))
+    const proc = await bash.start(bash.resolve({ command: 'echo err >&2' }))
     await proc.done
     expect(proc.readOutput().delta).toBe('[stderr]\nerr\n')
   })
 
   it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
+    const proc = await bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
     await proc.done
     expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
   })
 
   it('readOutput flags lossy reads and reports stdout spill paths', async () => {
     const { bash } = await setup({ maxOutputBytes: 100 })
-    const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
+    const proc = await bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
     await proc.done
     const read = proc.readOutput()
     // Window slid past offset 0 → lossy, spill path points at the full stream.
@@ -235,7 +235,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('readOutput reports stderr spill paths', async () => {
     const { bash } = await setup({ maxOutputBytes: 100 })
-    const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
+    const proc = await bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
     await proc.done
     const read = proc.readOutput()
     expect(read.lossy).toBe(true)
@@ -245,7 +245,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('kill() requests managed-range termination: true once, false after settlement', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
+    const proc = await bash.start(bash.resolve({ command: 'sleep 60' }))
     expect(proc.kill()).toBe(true)
     await proc.done
     expect(proc.status).toBe('killed')
@@ -255,7 +255,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('kill() returns false for a naturally completed process', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'true' }))
+    const proc = await bash.start(bash.resolve({ command: 'true' }))
     await proc.done
     expect(proc.status).toBe('completed')
     expect(proc.kill()).toBe(false)
@@ -266,7 +266,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
     // The child echoes AFTER arming the trap, so waiting for the marker
     // guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
     // is load-flaky: a slow spawn would take the SIGTERM before the trap).
-    const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
+    const proc = await bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
     await readUntil(proc, 'armed')
     proc.kill()
     await proc.done
@@ -277,7 +277,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
   it('a spec.signal abort settles the handle as killed, not completed', async () => {
     const { bash } = await setup()
     const controller = new AbortController()
-    const proc = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
+    const proc = await bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
     controller.abort()
     await proc.done
     expect(proc.status).toBe('killed')
@@ -286,7 +286,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('a self-signal exit settles the handle as killed, not completed', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'kill -TERM $$' }))
+    const proc = await bash.start(bash.resolve({ command: 'kill -TERM $$' }))
     await proc.done
     expect(proc.status).toBe('killed')
     expect(proc.exitCode).toBeNull()
@@ -317,7 +317,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
       waitForExit: async () => true,
     } satisfies SubprocessHandle)
 
-    const proc = bash.start(bash.resolve({ command: 'true' }))
+    const proc = await bash.start(bash.resolve({ command: 'true' }))
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
     const output = proc.readOutput().delta
@@ -347,7 +347,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
       waitForExit: async () => true,
     } satisfies SubprocessHandle)
 
-    const proc = bash.start(bash.resolve({ command: 'true' }))
+    const proc = await bash.start(bash.resolve({ command: 'true' }))
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
     expect(proc.readOutput().delta).toContain('unprintable provider failure')
@@ -356,7 +356,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
 
   it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
+    const proc = await bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
     // done resolves (never rejects) even though the process never ran.
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
@@ -374,7 +374,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
 
     // The child prints its own pid ($$ = the detached bash group leader) so
     // the test can probe liveness through the public read API alone.
-    const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
+    const proc = await bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
     const pid = Number((await readUntil(proc, '\n')).trim())
     expect(Number.isInteger(pid) && pid > 0).toBe(true)
 
@@ -399,10 +399,10 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
     await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
     const bash = ctx.shell as LocalBashExecutor
 
-    const finished = bash.start(bash.resolve({ command: 'echo done' }))
+    const finished = await bash.start(bash.resolve({ command: 'echo done' }))
     await finished.done
     expect(finished.status).toBe('completed')
-    const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
+    const trapping = await bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
     await readUntil(trapping, 'armed')
 
     await managerFiber.dispose()

+ 2 - 2
packages/shell/bash-sandbox/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/bash-sandbox/README.md
-README.md: 582d92f7c0496b166df9bb6b70c138d39bbe4fd3
-README.zh.md: d035d3f7983a4c61a93125df321291cbcda6e4c3
+README.md: 65b7070b108b8ecd52a85eb30d1ff5588bcbc942
+README.zh.md: db255f801f1dd3d3b89f33cffcfb1f5854fdedac

+ 2 - 2
packages/shell/bash-sandbox/README.md

@@ -75,7 +75,7 @@ This section explains the design of the executor and points at the code that rea
 
 ### Design concept
 
-The executor is the sandboxing Service Provider for the `ctx.shell` seam: it inherits `dsh-bash-local`'s process mechanics and re-wraps each command's exact `['bash', '-c', command]` argv through `ctx.sandbox.confine()`, spawning the returned argv directly. Which platform runner confines the command — and whether one is usable at all — is the provider's concern; this package owns the bash side only: the selected mode, enforcement completeness, and denial classification on results.
+The executor is the sandboxing Service Provider for the `ctx.shell` seam: it inherits `dsh-bash-local`'s process mechanics and awaits confinement of each command's exact `['bash', '-c', command]` argv through `ctx.sandbox.confine()`, spawning the returned argv directly. Foreground and background preparation carry the execution signal and recheck cancellation before spawn. Which platform runner confines the command — and whether one is usable at all — is the provider's concern; this package owns the bash side only: the selected mode, enforcement completeness, and denial classification on results.
 
 ### Source map
 
@@ -170,7 +170,7 @@ These limits define when this executor is not a general security boundary. They
 
 - **Confinement covers file effects only** — network restriction and a uniform process-visibility guarantee are absent, so the modes are not a general-purpose security sandbox.
 - **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
-- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous subprocess throw that names the runner path instead fails `start()` immediately.
+- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous subprocess throw that names the runner path rejects `start()` before a handle is published.
 - **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
 
 <a id="dev-note"></a>

+ 2 - 2
packages/shell/bash-sandbox/README.zh.md

@@ -75,7 +75,7 @@ kind: "package-reference"
 
 ### 设计概念
 
-本执行器是 `ctx.shell` seam 的沙箱 Service Provider:它继承 `dsh-bash-local` 的进程机制,把每条命令的精确 `['bash', '-c', command]` argv 经 `ctx.sandbox.confine()` 重新包装,并直接 spawn 返回的 argv。由哪种平台 runner 限制命令、以及是否有 runner 可用,属于提供方职责;本包只负责 bash 侧:所选模式、强制执行完整度,以及结果上的拒绝分类。
+本执行器是 `ctx.shell` seam 的沙箱 Service Provider:它继承 `dsh-bash-local` 的进程机制,通过 `ctx.sandbox.confine()` 等待每条命令的精确 `['bash', '-c', command]` argv 完成限制准备,再直接 spawn 返回的 argv。前台和后台准备均携带执行信号,并在 spawn 前重新检查取消状态。由哪种平台 runner 限制命令、以及是否有 runner 可用,属于提供方职责;本包只负责 bash 侧:所选模式、强制执行完整度,以及结果上的拒绝分类。
 
 ### 源码地图
 
@@ -170,7 +170,7 @@ kind: "package-reference"
 
 - **限制只覆盖文件影响**——不提供网络限制和统一的进程可见性保证,因此这些模式不是通用安全沙箱。
 - **拒绝从失败命令的 stderr 推断**——后端特征使该推断可跨平台使用,但包含相同特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
-- **异步观测到的后台 runner 失败没有即时错误通道**——它记录在已结算进程上,并在调用方用 `job_output` 读取通用任务时呈现;同步抛出且指明 runner 路径的子进程错误则会让 `start()` 立即失败
+- **异步观测到的后台 runner 失败没有即时错误通道**——它记录在已结算进程上,并在调用方用 `job_output` 读取通用任务时呈现;同步 subprocess throw 若指明 runner 路径,则会在发布句柄前拒绝 `start()`
 - **`danger-full-access` 有意绕过 `ctx.sandbox`**——它是显式无约束模式,不是更宽的沙箱 profile。
 
 <a id="dev-note"></a>

+ 8 - 7
packages/shell/bash-sandbox/src/index.ts

@@ -93,7 +93,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
       const result = await super.run(spec)
       return { ...result, sandbox: { mode, denied: false } }
     }
-    const confined = this.confine(spec.command, { ...policy, mode })
+    const confined = await this.confine(spec.command, { ...policy, mode }, spec.signal)
+    spec.signal?.throwIfAborted()
     let result: ShellRunResult
     try {
       result = await this.runArgv(spec, confined.argv)
@@ -114,13 +115,12 @@ export class SandboxBashExecutor extends LocalBashExecutor {
     return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
   }
 
-  override start(spec: ShellExecSpec): ShellProcess {
+  override async start(spec: ShellExecSpec): Promise<ShellProcess> {
     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.command, { ...policy, mode })
+    const confined = await this.confine(spec.command, { ...policy, mode }, spec.signal)
+    spec.signal?.throwIfAborted()
     let proc: ShellProcess
     try {
       proc = this.startArgv(spec, confined.argv)
@@ -174,10 +174,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
    * executor's subprocess path.
    * @param command - shell source for the confined inner `bash -c`.
    * @param policy - resolved confined execution policy.
+   * @param signal - cancellation of confinement preparation.
    * @returns the provider's exact argv and settlement-classification facts.
    */
-  private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
-    return this.ctx.sandbox.confine(['bash', '-c', command], policy)
+  private confine(command: string, policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
+    return this.ctx.sandbox.confine(['bash', '-c', command], policy, signal)
   }
 }
 

+ 1 - 1
packages/shell/bash-sandbox/tests/bwrap.e2e.ts

@@ -79,7 +79,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
   it('classifies a background denial once the task settles', async () => {
     const workdir = await tempDir(homedir())
     const bash = await sandboxedBash(workdir, 'read-only')
-    const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+    const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
     expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)

+ 44 - 0
packages/shell/bash-sandbox/tests/fixtures/async-confinement-failure.ts

@@ -0,0 +1,44 @@
+/** Snapshot provider whose asynchronous refusal records every attempted underlying spawn. */
+import { writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
+import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+
+export const name = 'snapshot-async-confinement-failure'
+
+/**
+ * Mount a refusing sandbox and an underlying-spawn tripwire through normal Cordis services.
+ * @param ctx - scenario-owned composition context.
+ */
+export async function apply(ctx: Context): Promise<void> {
+  let confineCalls = 0
+  let spawnCalls = 0
+  const audit = (): void => {
+    writeFileSync(join(process.cwd(), 'confinement-audit.json'), JSON.stringify({ confineCalls, spawnCalls }) + '\n')
+  }
+  class GuardedSubprocess extends LocalSubprocessRuntime {
+    override spawn(): never {
+      spawnCalls++
+      audit()
+      throw new Error('unexpected subprocess allocation after confinement refusal')
+    }
+    override async spawnTerminal(): Promise<never> {
+      spawnCalls++
+      audit()
+      throw new Error('unexpected terminal allocation after confinement refusal')
+    }
+  }
+  class RefusingSandbox extends SandboxProvider {
+    override async confine(_argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
+      await Promise.resolve()
+      signal?.throwIfAborted()
+      confineCalls++
+      audit()
+      throw new SandboxUnavailableError(policy.mode, 'fixture asynchronous confinement refused')
+    }
+  }
+  await ctx.plugin(GuardedSubprocess)
+  await ctx.plugin(RefusingSandbox)
+}

+ 1 - 1
packages/shell/bash-sandbox/tests/landlock.e2e.ts

@@ -84,7 +84,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
   it('classifies a background denial once the task settles', async () => {
     const workdir = await tempDir(homedir())
     const bash = await sandboxedBash(workdir, 'read-only')
-    const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+    const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
     expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)

+ 7 - 7
packages/shell/bash-sandbox/tests/partial-landlock.spec.ts

@@ -97,7 +97,7 @@ describe('partial Landlock runner-failure classification', () => {
     expect(error).toBeInstanceOf(Error)
     expect((error as Error).message).toContain(runner)
 
-    const task = bash.start(bash.resolve({ command: 'true' }))
+    const task = await bash.start(bash.resolve({ command: 'true' }))
     await task.done
     expect(task.status).toBe('killed')
     expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner}`)
@@ -131,7 +131,7 @@ describe('partial Landlock runner-failure classification', () => {
       // argv[0] in this spawn error rather than resolving it to an absolute path.
       expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
 
-      const task = bash.start(bash.resolve(request))
+      const task = await bash.start(bash.resolve(request))
       await task.done
       expect(task.status).toBe('killed')
       expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner} ENOENT`)
@@ -164,7 +164,7 @@ describe('partial Landlock runner-failure classification', () => {
 
       let background: unknown
       try {
-        bash.start(bash.resolve(request))
+        await bash.start(bash.resolve(request))
       } catch (error) {
         background = error
       }
@@ -179,7 +179,7 @@ describe('partial Landlock runner-failure classification', () => {
       })
       expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
 
-      const background = bash.start(bash.resolve(request))
+      const background = await bash.start(bash.resolve(request))
       await background.done
       expect(background.status).toBe('completed')
       expect(background.exitCode).toBe(127)
@@ -241,7 +241,7 @@ describe('partial Landlock runner-failure classification', () => {
   it('applies the same evidence rule to notice-only background exits', async () => {
     const bash = await setup()
     for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
-      const task = bash.start(bash.resolve({ command }))
+      const task = await bash.start(bash.resolve({ command }))
       await task.done
       expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
       expect(task.readOutput().delta).toContain(NOTICE)
@@ -250,7 +250,7 @@ describe('partial Landlock runner-failure classification', () => {
 
   it('classifies a background notice plus child Permission denied as denial', async () => {
     const bash = await setup()
-    const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
+    const task = await bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
     expect(task.readOutput().delta).toContain(NOTICE)
@@ -258,7 +258,7 @@ describe('partial Landlock runner-failure classification', () => {
 
   it('makes a background fatal line outrank denial text after the notice', async () => {
     const bash = await setup(LAUNCHER_FAILURE_EXIT)
-    const task = bash.start(bash.resolve({ command: 'true' }))
+    const task = await bash.start(bash.resolve({ command: 'true' }))
     await task.done
     expect(task.sandbox).toEqual({
       mode: 'read-only',

+ 70 - 21
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -56,14 +56,14 @@ const passthrough = (argv: readonly string[]): ConfinedArgv =>
  */
 async function setup(
   config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
-  behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
+  behavior: (argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal) => ConfinedArgv | Promise<ConfinedArgv> = passthrough,
 ) {
   const { mode, workspaceRoot, ...execConfig } = config
   const calls: ConfineCall[] = []
   class FakeSandboxProvider extends SandboxProvider {
-    confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
+    async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
       calls.push({ argv: [...argv], policy })
-      return behavior(argv, policy)
+      return behavior(argv, policy, signal)
     }
   }
   const ctx = new Context()
@@ -93,6 +93,55 @@ function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd(
 }
 
 describe('the provider hand-off', () => {
+  it.each(['run', 'start'] as const)('cancels %s while confinement is pending without spawning', async (operation) => {
+    const entered = Promise.withResolvers<AbortSignal>()
+    const response = Promise.withResolvers<ConfinedArgv>()
+    const { ctx, bash } = await setup({}, (_argv, _policy, signal) => {
+      entered.resolve(signal!)
+      return response.promise
+    })
+    const spawn = vi.spyOn(ctx.subprocess, 'spawn')
+    const controller = new AbortController()
+    const reason = new Error('cancel pending confinement')
+    const pending = bash[operation](bash.resolve({ command: 'true', signal: controller.signal }))
+    const rejected = expect(pending).rejects.toBe(reason)
+    try {
+      const signal = await entered.promise
+      expect(spawn).not.toHaveBeenCalled()
+      controller.abort(reason)
+      expect(signal.aborted).toBe(true)
+      response.resolve(passthrough(['bash', '-c', 'true']))
+      await rejected
+      expect(spawn).not.toHaveBeenCalled()
+    } finally {
+      response.resolve(passthrough(['bash', '-c', 'true']))
+      await pending.catch(() => {})
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('publishes a background process only after confinement completes', async () => {
+    const entered = Promise.withResolvers<undefined>()
+    const response = Promise.withResolvers<ConfinedArgv>()
+    const { ctx, bash } = await setup({}, () => { entered.resolve(undefined); return response.promise })
+    const spawn = vi.spyOn(ctx.subprocess, 'spawn')
+    let published = false
+    const pending = bash.start(bash.resolve({ command: 'printf ready' })).then((process) => { published = true; return process })
+    try {
+      await entered.promise
+      expect(published).toBe(false)
+      expect(spawn).not.toHaveBeenCalled()
+      response.resolve(passthrough(['bash', '-c', 'printf ready']))
+      const process = await pending
+      await process.done
+      expect(process.readOutput().delta).toBe('ready')
+      expect(spawn).toHaveBeenCalledOnce()
+    } finally {
+      response.resolve(passthrough(['bash', '-c', 'printf ready']))
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
     const { bash, calls } = await setup()
     const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
@@ -163,7 +212,7 @@ describe('the provider hand-off', () => {
   it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
     const { bash, calls } = await setup()
     await bash.run(bash.resolve({ command: 'true' }))
-    const task = bash.start(bash.resolve({ command: 'true' }))
+    const task = await bash.start(bash.resolve({ command: 'true' }))
     await task.done
     expect(calls).toHaveLength(2)
   })
@@ -175,7 +224,7 @@ describe('fail closed', () => {
     const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
     const spec = bash.resolve({ command: 'echo hi' })
     await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
-    expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
+    await expect(bash.start(spec)).rejects.toThrow(SandboxUnavailableError)
   })
 
   it('preserves an already-aborted foreground call as cancellation', async () => {
@@ -238,7 +287,7 @@ describe('fail closed', () => {
 
     let background: unknown
     try {
-      bash.start(bash.resolve({ command: 'true' }))
+      await bash.start(bash.resolve({ command: 'true' }))
     } catch (error) {
       background = error
     }
@@ -262,8 +311,8 @@ describe('fail closed', () => {
 
     await expect(bash.run(bash.resolve({ command: 'true' })))
       .rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
-    expect(() => bash.start(bash.resolve({ command: 'true' })))
-      .toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
+    await expect(bash.start(bash.resolve({ command: 'true' })))
+      .rejects.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
   })
 
   it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
@@ -281,7 +330,7 @@ describe('fail closed', () => {
     try {
       let thrown: unknown
       try {
-        bash.start(bash.resolve({ command: 'true', workdir }))
+        await bash.start(bash.resolve({ command: 'true', workdir }))
       } catch (error) {
         thrown = error
       }
@@ -304,7 +353,7 @@ describe('danger-full-access', () => {
 
   it('start() passes through unwrapped and stamps nothing at settle', async () => {
     const { bash, calls } = await setup({ mode: 'danger-full-access' })
-    const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
+    const task = await bash.start(bash.resolve({ command: 'echo free-bg' }))
     await task.done
     expect(task.sandbox).toBeUndefined()
     expect(task.readOutput().delta).toContain('free-bg')
@@ -347,8 +396,8 @@ describe('per-call sandbox policy (the session and escalation carrier)', () => {
     // once — anything keyed off the configured default would misreport the
     // escalated one at its settle stamp.
     const { bash } = await setup()
-    const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
-    const plain = bash.start(bash.resolve({ command: 'true' }))
+    const escalated = await bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
+    const plain = await bash.start(bash.resolve({ command: 'true' }))
     await plain.done
     await escalated.done
     expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
@@ -357,7 +406,7 @@ describe('per-call sandbox policy (the session and escalation carrier)', () => {
 
   it('an escalated danger-full-access background job carries no facts (nothing confined it)', async () => {
     const { bash, calls } = await setup()
-    const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
+    const task = await bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
     await task.done
     expect(task.sandbox).toBeUndefined()
     expect(task.readOutput().delta).toContain('bg-free')
@@ -437,7 +486,7 @@ describe('background sandbox facts', () => {
     }))
     const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
     try {
-      const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
+      const task = await bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
       await task.done
 
       expect(task.status).toBe('killed')
@@ -472,7 +521,7 @@ describe('background sandbox facts', () => {
       waitForExit: async () => true,
     } satisfies SubprocessHandle)
 
-    const task = bash.start(bash.resolve({ command: 'true' }))
+    const task = await bash.start(bash.resolve({ command: 'true' }))
     await task.done
 
     expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome: undefined')
@@ -485,7 +534,7 @@ describe('background sandbox facts', () => {
 
   it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
     const { bash } = await setup()
-    const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
+    const task = await bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
   })
@@ -508,7 +557,7 @@ describe('background sandbox facts', () => {
 
   it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
     const { bash } = await setup()
-    const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
+    const task = await bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
   })
@@ -526,8 +575,8 @@ describe('background sandbox facts', () => {
       const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
       return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
     })
-    const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
-    const quick = bash.start(bash.resolve({ command: 'true' }))
+    const slow = await bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
+    const quick = await bash.start(bash.resolve({ command: 'true' }))
     await quick.done
     await slow.done
     expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
@@ -536,7 +585,7 @@ describe('background sandbox facts', () => {
 
   it('a signal-killed task is never a denial (null exit code)', async () => {
     const { bash } = await setup()
-    const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
+    const task = await bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
     // Let the stderr land before the kill so the classifier sees the
     // signature and must still refuse it on the null exit code alone.
     await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
@@ -547,7 +596,7 @@ describe('background sandbox facts', () => {
 
   it('disposal kills wrapped background jobs (inherited HMR safety)', async () => {
     const { ctx, bash } = await setup()
-    const task = bash.start(bash.resolve({ command: 'sleep 30' }))
+    const task = await bash.start(bash.resolve({ command: 'sleep 30' }))
     await ctx.fiber.dispose()
     expect(task.status).toBe('killed')
   })

+ 1 - 1
packages/shell/bash-sandbox/tests/seatbelt.e2e.ts

@@ -107,7 +107,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
   it('classifies a background denial once the task settles', async () => {
     const workdir = await tempDir(homedir())
     const bash = await sandboxedBash(workdir, 'read-only')
-    const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+    const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
     await task.done
     expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
     expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)

+ 2 - 2
packages/shell/pwsh-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/pwsh-local/README.md
-README.md: 1d6872ec6b342f415b1632f87be5a485c4dfa270
-README.zh.md: cdd539475da994ad6c48e979d952f0fdf284a022
+README.md: 97e5ce74cb2b13382d8575fbdbb9befda3bca22e
+README.zh.md: ca8aba48051d04c8d2b81dc22cdadc5f3873c39a

+ 1 - 1
packages/shell/pwsh-local/README.md

@@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### Background processes
 
-Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
+Await `start` to run a command in the background; it resolves with the prepared process handle and no execution timeout applies. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### Adjusting budgets at runtime

+ 1 - 1
packages/shell/pwsh-local/README.zh.md

@@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 后台进程
 
-调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止由提供方管理的 range;`done` 在 direct command 关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+等待 `start` 即可在后台运行命令;它完成准备后返回进程句柄,且不应用执行超时。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止由提供方管理的 range;`done` 在 direct command 关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### 运行时调整预算

+ 3 - 2
packages/shell/pwsh-local/src/index.ts

@@ -278,13 +278,14 @@ export class PwshLocalExecutor extends ShellExecutor {
     }
   }
 
-  start(spec: ShellExecSpec): ShellProcess {
-    return this.startArgv(spec, this.argv(spec))
+  async start(spec: ShellExecSpec): Promise<ShellProcess> {
+    return Promise.resolve(this.startArgv(spec, this.argv(spec)))
   }
 
   /** Background start of an exact argv (the confining subclass re-wraps it). */
   protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
     // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
+    spec.signal?.throwIfAborted()
     const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
     const collected = PwshLocalExecutor.collected(running)
 

+ 20 - 20
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -238,7 +238,7 @@ describe('spawn construction (pure, every platform)', () => {
     subprocess.stderrText = 'target stderr'
     subprocess.done = Promise.reject(new Error('provider lost the direct outcome'))
 
-    const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
+    const proc = await ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
     const output = proc.readOutput().delta
@@ -258,7 +258,7 @@ describe('spawn construction (pure, every platform)', () => {
     })
     subprocess.done = Promise.reject(providerError)
 
-    const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
+    const proc = await ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
     expect(proc.readOutput().delta).toContain('unprintable provider failure')
@@ -272,7 +272,7 @@ describe('spawn construction (pure, every platform)', () => {
 
     const killedOutcome = Promise.withResolvers<SubprocessOutcome>()
     subprocess.done = killedOutcome.promise
-    const killed = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
+    const killed = await ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' }))
     expect(killed.kill()).toBe(true)
     killedOutcome.resolve({ exitCode: 0, signal: null })
     await killed.done
@@ -282,7 +282,7 @@ describe('spawn construction (pure, every platform)', () => {
     const abortedOutcome = Promise.withResolvers<SubprocessOutcome>()
     subprocess.done = abortedOutcome.promise
     const controller = new AbortController()
-    const aborted = ctx.shell.start(ctx.shell.resolve({
+    const aborted = await ctx.shell.start(ctx.shell.resolve({
       command: 'Write-Output maybe-ran',
       signal: controller.signal,
     }))
@@ -426,7 +426,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
   it('start returns immediately with a running handle that settles as completed', async ({ task }) => {
     const { bash } = await setup()
     const barrier = commandBarrier()
-    const proc = bash.start(bash.resolve({
+    const proc = await bash.start(bash.resolve({
       command: `Write-Output ready; [Console]::Out.Flush(); ${barrier.command}; Write-Output done`,
       env: barrier.env,
     }))
@@ -443,7 +443,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('threads stdin and extra env into a background process', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({
+    const proc = await bash.start(bash.resolve({
       command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
       stdin: 'bg-stdin\n',
       env: { BG_VAR: 'bg-env' },
@@ -459,7 +459,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
   it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async ({ task }) => {
     const { bash } = await setup()
     const barrier = commandBarrier()
-    const proc = bash.start(bash.resolve({
+    const proc = await bash.start(bash.resolve({
       command: `Write-Output first; [Console]::Out.Flush(); ${barrier.command}; Write-Output second`,
       env: barrier.env,
     }))
@@ -480,28 +480,28 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('readOutput marks stderr sections', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
+    const proc = await bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
     await proc.done
     expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
   })
 
   it('readOutput reports stderr-only deltas without a leading newline', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
+    const proc = await bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
     await proc.done
     expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n')
   })
 
   it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
+    const proc = await bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
     await proc.done
     expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
   })
 
   it('readOutput flags lossy reads and reports stdout spill paths', async () => {
     const { bash } = await setup({ maxOutputBytes: 100 })
-    const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
+    const proc = await bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
     await proc.done
     const read = proc.readOutput()
     // Window slid past offset 0 → lossy, spill path points at the full stream.
@@ -511,7 +511,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('readOutput reports stderr spill paths', async () => {
     const { bash } = await setup({ maxOutputBytes: 100 })
-    const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
+    const proc = await bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
     await proc.done
     const read = proc.readOutput()
     expect(read.lossy).toBe(true)
@@ -521,7 +521,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('kill() requests managed-range termination: true once, false after settlement', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
+    const proc = await bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
     expect(proc.kill()).toBe(true)
     await proc.done
     expect(proc.status).toBe('killed')
@@ -530,7 +530,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('kill() returns false for a naturally completed process', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Write-Output ok' }))
+    const proc = await bash.start(bash.resolve({ command: 'Write-Output ok' }))
     await proc.done
     expect(proc.status).toBe('completed')
     expect(proc.kill()).toBe(false)
@@ -539,7 +539,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
   it('a spec.signal abort settles the handle as killed, not completed', async () => {
     const { bash } = await setup()
     const controller = new AbortController()
-    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
+    const proc = await bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
     controller.abort()
     await proc.done
     expect(proc.status).toBe('killed')
@@ -547,7 +547,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
+    const proc = await bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
     await proc.done
     expect(proc.status).toBe('killed')
     expect(proc.exitCode).toBeNull()
@@ -557,7 +557,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
   it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => {
     const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
+    const proc = await bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
     // done resolves (never rejects) even though the process never ran.
     await expect(proc.done).resolves.toBeUndefined()
     expect(proc.status).toBe('killed')
@@ -575,7 +575,7 @@ describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service,
 
     // The child prints its own pid so the test can probe liveness through the
     // public read surface alone.
-    const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
+    const proc = await bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
     const pid = Number((await readUntil(proc, '\n', task.timeout)).trim())
     expect(Number.isInteger(pid) && pid > 0).toBe(true)
 
@@ -605,10 +605,10 @@ describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service,
     await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
     const bash = ctx.shell as PwshLocalExecutor
 
-    const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
+    const finished = await bash.start(bash.resolve({ command: 'Write-Output done' }))
     await finished.done
     expect(finished.status).toBe('completed')
-    const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
+    const running = await bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
 
     await managerFiber.dispose()
     // A settled process was untouched; the live one was terminated and joined.

+ 2 - 2
packages/shell/pwsh-sandbox/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md
-README.md: cb0170d5d0cc4f87ea4811c4fcb8c29fb7525f3c
-README.zh.md: 32e72071af1fd9be7e76b6357988620b7cbb523e
+README.md: b351d0d617c0124286c14a356dcaeb86584ba093
+README.zh.md: 8ce15f52c8ff7ad4b4497f018f50b7baf986ad0e

+ 1 - 1
packages/shell/pwsh-sandbox/README.md

@@ -75,7 +75,7 @@ This section explains the design of the executor and points at the code that rea
 
 ### Design concept
 
-The executor is the pwsh twin of `dsh-bash-sandbox`: it inherits `dsh-pwsh-local`'s process mechanics, consumes its argv-level seam (`argv()`/`runArgv()`/`startArgv()`/`onProcessDone()`), and wraps the exact pwsh invocation through `ctx.sandbox.confine()` before spawning. The confinement substance is platform-neutral — the sandbox seam resolves to the platform's runner — while this package owns the pwsh side only: the selected mode, enforcement completeness, and denial classification on results.
+The executor is the pwsh twin of `dsh-bash-sandbox`: it inherits `dsh-pwsh-local`'s process mechanics, consumes its argv-level seam (`argv()`/`runArgv()`/`startArgv()`/`onProcessDone()`), and awaits confinement of the exact pwsh invocation through `ctx.sandbox.confine()` before spawning. Foreground and background preparation carry the execution signal and recheck cancellation before spawn. The confinement substance is platform-neutral — the sandbox seam resolves to the platform's runner — while this package owns the pwsh side only: the selected mode, enforcement completeness, and denial classification on results.
 
 ### Source map
 

+ 1 - 1
packages/shell/pwsh-sandbox/README.zh.md

@@ -75,7 +75,7 @@ kind: "package-reference"
 
 ### 设计概念
 
-本执行器是 `dsh-bash-sandbox` 的 pwsh 孪生:它继承 `dsh-pwsh-local` 的进程机制,消费其 argv 级 seam(`argv()`/`runArgv()`/`startArgv()`/`onProcessDone()`),并在 spawn 前把精确的 pwsh 调用经 `ctx.sandbox.confine()` 包装。隔离实体本身是平台无关的——沙箱 seam 解析到平台的 runner——而本包只负责 pwsh 侧:所选模式、强制执行完整度,以及结果上的拒绝分类。
+本执行器是 `dsh-bash-sandbox` 的 pwsh 孪生:它继承 `dsh-pwsh-local` 的进程机制,消费其 argv 级 seam(`argv()`/`runArgv()`/`startArgv()`/`onProcessDone()`),并在 spawn 前通过 `ctx.sandbox.confine()` 等待精确的 pwsh 调用完成限制准备。前台和后台准备均携带执行信号,并在 spawn 前重新检查取消状态。隔离实体本身是平台无关的——沙箱 seam 解析到平台的 runner——而本包只负责 pwsh 侧:所选模式、强制执行完整度,以及结果上的拒绝分类。
 
 ### 源码地图
 

+ 7 - 7
packages/shell/pwsh-sandbox/src/index.ts

@@ -100,7 +100,8 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
       const result = await super.run(spec)
       return { ...result, sandbox: { mode, denied: false } }
     }
-    const confined = this.confine(spec, { ...policy, mode })
+    const confined = await this.confine(spec, { ...policy, mode })
+    spec.signal?.throwIfAborted()
     let result: ShellRunResult
     try {
       result = await this.runArgv(spec, confined.argv)
@@ -121,13 +122,12 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
     return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
   }
 
-  override start(spec: ShellExecSpec): ShellProcess {
+  override async start(spec: ShellExecSpec): Promise<ShellProcess> {
     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 })
+    const confined = await this.confine(spec, { ...policy, mode })
+    spec.signal?.throwIfAborted()
     let proc: ShellProcess
     try {
       proc = this.startArgv(spec, confined.argv)
@@ -181,8 +181,8 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
    * @param policy - resolved confined execution policy.
    * @returns the provider's exact argv and settlement-classification facts.
    */
-  private confine(spec: ShellExecSpec, policy: SandboxPolicy): ConfinedArgv {
-    return this.ctx.sandbox.confine(this.argv(spec), policy)
+  private confine(spec: ShellExecSpec, policy: SandboxPolicy): Promise<ConfinedArgv> {
+    return this.ctx.sandbox.confine(this.argv(spec), policy, spec.signal)
   }
 }
 /* jscpd:ignore-end */

+ 34 - 8
packages/shell/pwsh-sandbox/tests/sandbox.spec.ts

@@ -10,7 +10,7 @@ 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 { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context, Service } from '@deepseek-ai/cordis'
 import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
 import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
@@ -54,14 +54,14 @@ function throwingSubprocessRuntime(error: unknown): new (ctx: Context) => Servic
 }
 
 async function setup(
-  behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
+  behavior: (argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal) => ConfinedArgv | Promise<ConfinedArgv> = passthrough,
   subprocess: new (ctx: Context) => Service = LocalSubprocessRuntime,
 ): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
   const calls: ConfineCall[] = []
   class FakeSandboxProvider extends SandboxProvider {
-    confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
+    async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
       calls.push({ argv: [...argv], policy })
-      return behavior(argv, policy)
+      return behavior(argv, policy, signal)
     }
   }
   const ctx = new Context()
@@ -151,6 +151,32 @@ describe('helpers (pure)', () => {
   })
 })
 
+describe('SandboxPwshExecutor asynchronous confinement', () => {
+  it.each(['run', 'start'] as const)('cancels %s before a late confinement reply can spawn', async (operation) => {
+    const entered = Promise.withResolvers<AbortSignal>()
+    const response = Promise.withResolvers<ConfinedArgv>()
+    const spawned = vi.fn()
+    class Subprocess extends Service {
+      constructor(ctx: Context) { super(ctx, 'subprocess') }
+      spawn(): never { spawned(); throw new Error('unexpected process allocation') }
+    }
+    const { executor } = await setup((_argv, _policy, signal) => {
+      entered.resolve(signal!)
+      return response.promise
+    }, Subprocess)
+    const controller = new AbortController()
+    const reason = new Error('cancel pending confinement')
+    const pending = executor[operation](executor.resolve({ command: 'Write-Output ready', signal: controller.signal }))
+    const rejected = expect(pending).rejects.toBe(reason)
+    const signal = await entered.promise
+    controller.abort(reason)
+    expect(signal.aborted).toBe(true)
+    response.resolve(passthrough(['pwsh']))
+    await rejected
+    expect(spawned).not.toHaveBeenCalled()
+  })
+})
+
 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) —
@@ -283,7 +309,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
 
   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 }))
+    const clean = await 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)
@@ -292,7 +318,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
   // 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({
+    const denied = await executor.start(executor.resolve({
       command: deniedWriteCommand,
       sandboxPolicy: RO,
     }))
@@ -307,7 +333,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
       denialSignatures: [],
       runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
     }))
-    const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
+    const proc = await 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.
@@ -317,7 +343,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
 
   it('danger-full-access background runs bypass confine and carry no facts', async () => {
     const { executor, calls } = await setup()
-    const proc = executor.start(executor.resolve({
+    const proc = await executor.start(executor.resolve({
       command: 'echo full-bg',
       sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
     }))

+ 2 - 2
packages/shell/shell/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/shell/README.md
-README.md: eadfce7bdc895fa5685f49f40da859bac36f83cc
-README.zh.md: 8aaddc18a1f99068669ebd6afdd5e2c945f63ba0
+README.md: 9b32149691a8cf4d4d8316101f4feec6762d0846
+README.zh.md: 9d23b0c5957f9d59169c53c92b960c0b26b77a23

+ 3 - 3
packages/shell/shell/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use `ctx.shell` to run foreground shell commands with bounded output or start background processes that return a handle immediately. A profile can select local or sandboxed Bash or PowerShell execution without changing callers. Resolve each request before execution to make the working directory, timeout, and output limits explicit. Command completion, nonzero exits, timeouts, and caller aborts return results; only infrastructure failures reject, while the `bash` and `pwsh` tools own model-visible rendering and sandbox guidance.
+Use `ctx.shell` to run foreground shell commands with bounded output or prepare background processes asynchronously before receiving their handles. A profile can select local or sandboxed Bash or PowerShell execution without changing callers. Resolve each request before execution to make the working directory, timeout, and output limits explicit. Command completion, nonzero exits, timeouts, and caller aborts return results; only infrastructure failures reject, while the `bash` and `pwsh` tools own model-visible rendering and sandbox guidance.
 
 ## Table of Contents
 
@@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text)
 
 ### Background processes
 
-Call `start` with a resolved spec to launch a background process; it returns a handle immediately and no timeout applies. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Terminate the provider-managed range with `kill()` (returns `false` once the direct command has finished) and await `done` for direct-command settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle.
+Await `start` with a resolved spec to launch a background process; it publishes the handle after preparation and applies no background execution timeout. Cancellation or preparation failure rejects before publication. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Terminate the provider-managed range with `kill()` (returns `false` once the direct command has finished) and await `done` for direct-command settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle.
 
 ### Requests and resolved specs
 
@@ -91,7 +91,7 @@ The package is one role of a standard capability seam: the Service Definition th
 
 ### Background lifecycle and ownership
 
-A background process belongs to the subprocess service, not to the executor: it survives an executor-only reload and is killed and joined when the composition tears down. Implementations must honor the seam's semantics — `run` rejects only for infrastructure failures; `start` returns immediately with no timeout and its `done` never rejects (a subprocess provider rejection settles as `killed` with a stage-neutral error on stderr); `readOutput` is consuming and lossy reads report spill files.
+A background process belongs to the subprocess service, not to the executor: it survives an executor-only reload and is killed and joined when the composition tears down. Implementations must honor the seam's semantics — `run` rejects only for infrastructure failures; `start` resolves after preparation with no background execution timeout and its published handle’s `done` never rejects (a subprocess provider rejection settles as `killed` with a stage-neutral error on stderr); `readOutput` is consuming and lossy reads report spill files.
 
 </details>
 

+ 3 - 3
packages/shell/shell/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用 `ctx.shell` 运行输出有界的前台 shell 命令,或启动立即返回句柄的后台进程。配置文件可选择本地或沙箱化的 Bash 或 PowerShell 执行方式,而无需更改调用方。执行前解析每个请求,以显式确定工作目录、超时和输出上限。命令完成、非零退出、超时和调用方中止都会作为结果返回;只有基础设施故障才会 reject,而模型可见的渲染与沙箱指引由 `bash` 和 `pwsh` 工具负责。
+使用 `ctx.shell` 运行输出有界的前台 shell 命令,或异步准备后台进程后取得句柄。配置文件可选择本地或沙箱化的 Bash 或 PowerShell 执行方式,而无需更改调用方。执行前解析每个请求,以显式确定工作目录、超时和输出上限。命令完成、非零退出、超时和调用方中止都会作为结果返回;只有基础设施故障才会 reject,而模型可见的渲染与沙箱指引由 `bash` 和 `pwsh` 工具负责。
 
 ## 目录
 
@@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text)
 
 ### 后台进程
 
-用已解析的 spec 调用 `start` 即可启动后台进程;它会立即返回句柄,且不应用任何超时。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止由提供方管理的进程范围(直接命令结束后返回 `false`),并等待 `done` 完成直接命令结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+用已解析的 spec 等待 `start` 即可启动后台进程;它在准备完成后发布句柄,不应用后台执行超时。取消或准备失败会在发布前拒绝调用。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止由提供方管理的进程范围(直接命令结束后返回 `false`),并等待 `done` 完成直接命令结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 ### 请求与已解析 spec
 
@@ -91,7 +91,7 @@ seam 本身不是执行器:每个组合只挂载一个提供方,工具即可
 
 ### 后台生命周期与归属
 
-后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject;`start` 立即返回且不设超时,其 `done` 绝不 reject(subprocess provider rejection 以 `killed` 结算,并把不声明阶段的错误写入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。
+后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject;`start` 在准备完成后返回且不设后台执行超时,已发布句柄的 `done` 绝不 reject(subprocess provider rejection 以 `killed` 结算,并把不声明阶段的错误写入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。
 
 </details>
 

+ 7 - 6
packages/shell/shell/src/index.ts

@@ -51,9 +51,10 @@ declare module '@deepseek-ai/cordis' {
  * Implementations must honor these semantics:
  * - {@link run} rejects only for infrastructure failures. Nonzero exits,
  *   timeout kills, and abort kills resolve with a {@link ShellRunResult}.
- * - {@link start} returns immediately; no timeout applies to background
- *   processes. `done` settles at process close and never rejects; spawn
- *   failures settle as `killed` with the error on stderr.
+ * - {@link start} resolves after launch preparation; cancellation or setup failure
+ *   rejects before publishing a handle. No timeout applies to background processes.
+ *   Once published, `done` settles at process close and never rejects; subprocess
+ *   provider failures settle as `killed` with the error on stderr.
  * - {@link ShellProcess.readOutput} is incremental: consecutive reads never
  *   repeat output. Lossy reads report truncation and available spill files.
  * - A still-running background process is stopped and awaited when its
@@ -92,11 +93,11 @@ export abstract class ShellExecutor extends Service {
   abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
 
   /**
-   * Start a background process and return its handle immediately.
+   * Prepare a background process asynchronously and publish its live handle.
    * @param spec - a resolved spec from {@link resolve}, never a raw request.
-   * @returns the live process handle (reads, kill, quiescence promise).
+   * @returns the live process handle after preparation; cancellation or setup failure rejects.
    */
-  abstract start(spec: ShellExecSpec): ShellProcess
+  abstract start(spec: ShellExecSpec): Promise<ShellProcess>
 }
 
 export default ShellExecutor

+ 2 - 2
packages/shell/shell/tests/service.spec.ts

@@ -33,7 +33,7 @@ class StubExecutor extends ShellExecutor {
     }
   }
 
-  start(): ShellProcess {
+  async start(): Promise<ShellProcess> {
     const proc: ShellProcess = {
       status: 'running',
       exitCode: null,
@@ -61,7 +61,7 @@ describe('ShellExecutor service seam', () => {
     expect(result.exitCode).toBe(0)
     expect(result.stdout.text).toBe('ok')
 
-    const proc = ctx.shell.start(spec)
+    const proc = await ctx.shell.start(spec)
     expect(proc.status).toBe('running')
     expect(proc.readOutput()).toEqual({ delta: '', lossy: false })
     expect(proc.kill()).toBe(true)

+ 1 - 1
packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -33,7 +33,7 @@ afterEach(async () => {
 })
 
 class PassthroughSandbox extends SandboxProvider {
-  confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
+  async confine(argv: readonly string[], _policy: SandboxPolicy): Promise<ConfinedArgv> {
     return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
   }
 }

+ 2 - 2
packages/shell/tool-bash/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md
-README.md: 5b38c0a0bad9e03be783ad8431cd06c2d3ba34d3
-README.zh.md: c922f2e47954c9541cc1bb11366ea9080d9a254b
+README.md: 43c70d7ebc8eb13f28c4e4576dfde8cb2402b775
+README.zh.md: e38e7afa6b7a534fe29d1022fc2797f5a98651b0

+ 3 - 2
packages/shell/tool-bash/README.md

@@ -53,9 +53,10 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 The tool executes `bash -c <command>` and returns the combined output. Commands run in a fresh shell every call, so state never persists — pass `workdir` instead of `cd`. A non-zero exit is reported as `[exit code: N]` for the agent to interpret, not surfaced as a tool error. A `description` in active voice (5–10 words) labels the call in the UI; `timeoutMs` overrides the executor's default and cap. Output beyond the executor's stream caps is truncated to its tail, with the full output saved to a spill file whose path is reported.
 
+<a id="running-long-commands-in-the-background"></a>
 ### Running long commands in the background
 
-Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted.
+Passing `run_in_background: true` admits a job and returns its id immediately; confinement preparation may still be pending, and no background execution timeout applies. Output is empty until the process is available. Job cancellation aborts preparation and stops any process that arrives afterward; startup failure settles the admitted job as failed. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted.
 
 ### Sandboxed execution and escalation
 
@@ -87,7 +88,7 @@ This section explains the design decisions behind the tool and points at the cod
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, prompt section, arg validation, escalation, request assembly |
-| [`src/background.ts`](src/background.ts) | Map a settled background process onto generic job outcome vocabulary |
+| [`src/background.ts`](src/background.ts) | Own asynchronous shell preparation and map process settlement onto job outcomes |
 | [`src/render.ts`](src/render.ts) | Model-facing result text: streams, markers, truncation notices |
 | — | No runtime invariant companion is published; the environment registry validates ownership and collected values at each mutation/read; it publishes no independent snapshot that a companion could cross-check. |
 

+ 3 - 2
packages/shell/tool-bash/README.zh.md

@@ -53,9 +53,10 @@ kind: "package-reference"
 
 工具执行 `bash -c <command>` 并返回合并后的输出。命令每次调用都运行在全新 shell 中,因此状态从不保留——请传 `workdir` 而不是 `cd`。非零退出以 `[exit code: N]` 报告给 agent 解读,而不是作为工具错误抛出。主动语态的 `description`(5–10 个词)在 UI 中标注该调用;`timeoutMs` 覆盖执行器的默认值与上限。超出执行器流上限的输出会被截断为尾部,完整输出保存到 spill 文件并报告其路径。
 
+<a id="running-long-commands-in-the-background"></a>
 ### 后台运行长时间命令
 
-传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。
+传入 `run_in_background: true` 会准入任务并立即返回 job id;限制准备可能仍在进行,且不设后台执行超时。进程可用前输出为空。任务取消会中止准备并停止随后返回的进程;启动失败使已准入任务以失败状态结算。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。
 
 ### 沙箱执行与升权
 
@@ -87,7 +88,7 @@ kind: "package-reference"
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、提示词区段、参数校验、升权、请求组装 |
-| [`src/background.ts`](src/background.ts) | 把已结算的后台进程映射为通用任务结果词汇 |
+| [`src/background.ts`](src/background.ts) | 管理异步 shell 准备,并将进程结算映射为任务结果 |
 | [`src/render.ts`](src/render.ts) | 模型侧结果文本:流、标记、截断通知 |
 | — | 不发布运行时不变式伴生入口;环境注册表在每次变更和读取时校验所有权及收集值,且不发布可供伴生入口交叉核对的独立快照;执行关系由能力 seam 负责。 |
 

+ 40 - 0
packages/shell/tool-bash/src/background.ts

@@ -5,6 +5,7 @@
  */
 
 import type { ShellProcess } from '@deepseek-ai/dsh-shell'
+import type { JobHooks, JobOutcome } from '@deepseek-ai/dsh-jobs'
 
 /**
  * Map a settled background process onto the generic task-outcome vocabulary:
@@ -25,3 +26,42 @@ export function processOutcome(proc: ShellProcess): { status: 'completed' | 'kil
   }
   return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
 }
+
+/**
+ * Adapt asynchronous shell preparation after job admission without exposing a partial process.
+ * @param start - starts the process with job-owned cancellation.
+ * @param renderOutput - consumes output from a published process.
+ * @returns synchronous job hooks whose completion includes preparation and process settlement.
+ */
+export function processJob(
+  start: (signal: AbortSignal) => Promise<ShellProcess>,
+  renderOutput: (process: ShellProcess) => string,
+): JobHooks {
+  const controller = new AbortController()
+  let process: ShellProcess | undefined
+  const done: Promise<JobOutcome> = (async () => {
+    try {
+      process = await start(controller.signal)
+      try {
+        if (controller.signal.aborted) process.kill()
+      } finally {
+        await process.done
+      }
+      return processOutcome(process)
+    } catch (error: unknown) {
+      return {
+        status: controller.signal.aborted && process === undefined ? 'killed' : 'failed',
+        detail: error instanceof Error ? error.message : String(error),
+      }
+    }
+  })()
+  return {
+    cancel: (reason) => {
+      if (controller.signal.aborted) return
+      controller.abort(reason)
+      process?.kill()
+    },
+    done,
+    readOutput: () => process === undefined ? '' : renderOutput(process),
+  }
+}

+ 9 - 13
packages/shell/tool-bash/src/index.ts

@@ -10,7 +10,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { isAbsolute, resolve as resolvePath } from 'node:path'
+import { isAbsolute, sep } from 'node:path'
 import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
 import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -19,11 +19,11 @@ import type {} from '@deepseek-ai/dsh-jobs'
 import type {} from '@deepseek-ai/dsh-user-approval'
 import type {} from '@deepseek-ai/dsh-shell-env'
 import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
-import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } 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 { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
 import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
-import { processOutcome } from './background.ts'
+import { processJob } from './background.ts'
 import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
 
 export const name = 'tool-bash'
@@ -146,10 +146,10 @@ function resolveWorkdir(
   policyWorkspaceRoot?: string,
 ): string | undefined {
   const headerCwd = exec.agent?.session.header.cwd
-  const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd))
+  const sessionCwd = policyWorkspaceRoot ?? headerCwd
   if (modelWorkdir === undefined) return sessionCwd
   if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
-    return resolvePath(sessionCwd, modelWorkdir)
+    return `${sessionCwd}${sep}${modelWorkdir}`
   }
   return modelWorkdir
 }
@@ -365,14 +365,10 @@ export function apply(ctx: Context, config: Config = {}): void {
           kind: 'bash',
           label: args.command,
           ...exec.agent ? { owner: exec.agent } : {},
-          run: () => {
-            const proc = ctx.shell.start(ctx.shell.resolve(request))
-            return {
-              cancel: () => void proc.kill(),
-              done: proc.done.then(() => processOutcome(proc)),
-              readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
-            }
-          },
+          run: () => processJob(
+            signal => ctx.shell.start(ctx.shell.resolve({ ...request, signal })),
+            proc => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
+          ),
         })
         return { kind: 'background' as const, jobId: id }
       }

+ 116 - 0
packages/shell/tool-bash/tests/background-start.spec.ts

@@ -0,0 +1,116 @@
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import type { ShellProcess } from '@deepseek-ai/dsh-shell'
+import { processJob } from '../src/background.ts'
+
+function processHandle() {
+  const exited = Promise.withResolvers<undefined>()
+  const killed = Promise.withResolvers<undefined>()
+  const kill = vi.fn(() => {
+    if (process.status !== 'running') return false
+    process.status = 'killed'
+    process.signal = 'SIGTERM'
+    killed.resolve(undefined)
+    return true
+  })
+  const process: ShellProcess = {
+    status: 'running', exitCode: null, signal: null, done: exited.promise,
+    readOutput: () => ({ delta: 'output', lossy: false }), kill,
+  }
+  onTestFinished(() => { exited.resolve(undefined) })
+  return { process, exited, killed, kill }
+}
+
+describe('background job ownership during asynchronous shell startup', () => {
+  it('keeps output empty until a process is published and then consumes its output', async () => {
+    const child = processHandle()
+    const prepared = Promise.withResolvers<ShellProcess>()
+    const render = vi.fn((process: ShellProcess) => process.readOutput().delta)
+    const hooks = processJob(() => prepared.promise, render)
+    expect(hooks.readOutput!()).toBe('')
+    expect(render).not.toHaveBeenCalled()
+    prepared.resolve(child.process)
+    await Promise.resolve()
+    expect(hooks.readOutput!()).toBe('output')
+    child.process.status = 'completed'
+    child.process.exitCode = 5
+    child.exited.resolve(undefined)
+    expect(await hooks.done).toEqual({ status: 'completed', detail: 'exit code: 5' })
+  })
+
+  it('aborts pending preparation through the job-owned signal', async () => {
+    let received: AbortSignal | undefined
+    const hooks = processJob((signal) => {
+      received = signal
+      return new Promise<ShellProcess>((_resolve, reject) => {
+        signal.addEventListener('abort', () => { reject(new Error(String(signal.reason))) }, { once: true })
+      })
+    }, () => 'unreachable')
+    expect(received?.aborted).toBe(false)
+    hooks.cancel('cancel pending confinement')
+    hooks.cancel('later cancellation')
+    expect(received?.reason).toBe('cancel pending confinement')
+    expect(await hooks.done).toEqual({ status: 'killed', detail: 'cancel pending confinement' })
+    expect(hooks.readOutput!()).toBe('')
+  })
+
+  it('kills a process that materializes after cancellation and awaits its exit', async () => {
+    const child = processHandle()
+    const prepared = Promise.withResolvers<ShellProcess>()
+    const hooks = processJob(() => prepared.promise, () => 'output')
+    let settled = false
+    void hooks.done.then(() => { settled = true })
+    hooks.cancel('cancel before process publication')
+    prepared.resolve(child.process)
+    await child.killed.promise
+    expect(child.kill).toHaveBeenCalledOnce()
+    expect(settled).toBe(false)
+    child.exited.resolve(undefined)
+    expect(await hooks.done).toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
+  })
+
+  it('cancels a published process once and keeps done pending until exit', async () => {
+    const child = processHandle()
+    const hooks = processJob(async () => child.process, () => 'output')
+    await Promise.resolve()
+    let settled = false
+    void hooks.done.then(() => { settled = true })
+    hooks.cancel('stop')
+    hooks.cancel('stop again')
+    await child.killed.promise
+    expect(child.kill).toHaveBeenCalledOnce()
+    expect(settled).toBe(false)
+    child.exited.resolve(undefined)
+    expect((await hooks.done).status).toBe('killed')
+  })
+
+  it('joins a late process even when its termination request throws', async () => {
+    const child = processHandle()
+    const attempted = Promise.withResolvers<undefined>()
+    vi.mocked(child.kill).mockImplementation(() => { attempted.resolve(undefined); throw new Error('termination failed') })
+    const prepared = Promise.withResolvers<ShellProcess>()
+    const hooks = processJob(() => prepared.promise, () => '')
+    let settled = false
+    void hooks.done.then(() => { settled = true })
+    hooks.cancel('stop before publication')
+    prepared.resolve(child.process)
+    await attempted.promise
+    expect(settled).toBe(false)
+    child.exited.resolve(undefined)
+    expect(await hooks.done).toEqual({ status: 'failed', detail: 'termination failed' })
+  })
+
+  it('reports a startup failure through job completion without publishing output', async () => {
+    const render = vi.fn(() => 'unreachable')
+    const hooks = processJob(async () => { throw new Error('remote sandbox unavailable') }, render)
+    expect(await hooks.done).toEqual({ status: 'failed', detail: 'remote sandbox unavailable' })
+    expect(hooks.readOutput!()).toBe('')
+    expect(render).not.toHaveBeenCalled()
+  })
+
+  it('reports a primitive upstream abort reason as a failed startup', async () => {
+    const upstream = new AbortController()
+    upstream.abort('upstream preparation stopped')
+    const hooks = processJob(async () => { upstream.signal.throwIfAborted(); throw new Error('unreachable') }, () => '')
+    expect(await hooks.done).toEqual({ status: 'failed', detail: 'upstream preparation stopped' })
+  })
+})

+ 3 - 3
packages/shell/tool-bash/tests/tools.spec.ts

@@ -143,7 +143,7 @@ class RecordingSandboxExecutor extends ShellExecutor {
     })
   }
 
-  start(spec: ShellExecSpec): ShellProcess {
+  async start(spec: ShellExecSpec): Promise<ShellProcess> {
     this.modes.push(spec.sandboxPolicy?.mode)
     return {
       status: 'completed',
@@ -173,7 +173,7 @@ class CountingStartExecutor extends ShellExecutor {
 
   run(): Promise<ShellRunResult> { return Promise.reject(new Error('unused')) }
 
-  start(): ShellProcess {
+  async start(): Promise<ShellProcess> {
     this.starts += 1
     return {
       status: 'completed',
@@ -1107,7 +1107,7 @@ describe('the model-facing bash tool builds its request from named args only (no
         stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
       })
     }
-    start(): ShellProcess {
+    async start(): Promise<ShellProcess> {
       return {
         status: 'completed',
         exitCode: 0,

部分文件因为文件数量过多而无法显示