fix(subprocess): clarify provider failures and scope polling

This commit is contained in:
pku-xht
2026-08-31 19:26:08 +08:00
parent 6d49ac2ef6
commit fc19a0a3fa
51 changed files with 232 additions and 146 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
2026-07-26-subprocess-seam.md: b892c43a4027815692dcc8082d4a2cc9feea18c7
2026-07-26-subprocess-seam.zh.md: 78e290a14bc0cdf50434462dd854bdc1355d4938
2026-07-26-subprocess-seam.md: 359e0c60997cb51df427db69c02e0200f293d07c
2026-07-26-subprocess-seam.zh.md: 56dd228c98a5eb71cb809c9f0eab954ed414967d
@@ -19,7 +19,7 @@ A new `subprocess/` capability family owns "run and manage a process"; the bash
Every composition that loads a bash executor also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, the Python bundled runtime, and inline test configs).
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: the subprocess service rejects `done` when spawn or provider failure prevents a direct outcome, without exposing whether target execution began. The executor therefore injects the stage-neutral `subprocess failed before reporting an outcome: …` note into exactly one `readOutput()` delta instead of manufacturing process stderr.
Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.
@@ -19,7 +19,7 @@ Status: implemented
每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时以及各内联测试配置。
后台进程的存续期从执行器移到了服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,服务会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
后台进程的存续期从执行器移到了服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动: spawn 或 provider failure 使 direct outcome 无法产生时,subprocess 服务会 reject `done`,但不会公开 target 是否已经开始执行。因此执行器不再伪造进程 stderr,而是把不声明阶段的 `subprocess failed before reporting an outcome: …` 提示注入恰好一个 `readOutput()` 增量。
基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACPAgent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
2026-08-28-subprocess-native-containment.md: 2ece0bf300336dab82c42d12fd97e2eaf4cad1f9
2026-08-28-subprocess-native-containment.zh.md: b59ed6e6c5ef984e3c0162921fd0f81359bcc5d5
2026-08-28-subprocess-native-containment.md: 378a109b7d9b16593eda4ae06ab45cb27a6319c0
2026-08-28-subprocess-native-containment.zh.md: d0bdf71eb8602896adab853bd7d3c2a969caad21
@@ -22,7 +22,7 @@ Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the
The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
Request consumption or a manager observation of the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. After establishment, an inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
Request consumption or a manager observation of the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. The parent checks this unresolved interval every 50 milliseconds; after establishment, active-state queries back off exponentially to the existing 5-second systemctl bound. An inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
@@ -32,7 +32,7 @@ The Windows parent starts the provider runner from a bootstrap cwd and environme
The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`.
The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof.
The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`, but that rejection does not reveal whether target execution began. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof.
### Private dispatch and protocol
@@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th
## Verification
- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, and exactly-once PTY managed-owner cleanup.
- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, prompt pre-establishment polling with bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and invalid-result rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
- Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
- Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 requestrunner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。建立后,inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 requestbootstrap 或 target pre-exec failureparent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。parent 每 50 毫秒检查一次这段未决区间;建立后,active-state 查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 requestbootstrap 或 target pre-exec failureparent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting``/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
@@ -32,7 +32,7 @@ Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 tar
runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`
parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdoutstderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。
parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdoutstderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject,但该 rejection 不会说明 target 是否已经开始执行。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。
### 私有分派与协议
@@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
## Verification
- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 requesterror 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。
- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 requesterror 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、建立前快速轮询与建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM``-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与无效结果拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
- 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
- native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
@@ -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: c4153c6ef47b6d11290094adf06b8107418be889
2026-07-06-sandbox.zh.md: 9191b1b8d2d4e65acab8b935d0da170066876aea
2026-07-06-sandbox.md: ed8cac5f98629ee749f8bb10f62b17ef47c44a16
2026-07-06-sandbox.zh.md: a5014d41251984c2f28a1ef164b9593a2ad49f84
@@ -38,7 +38,7 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro
The swap is invisible to every consumer of `ctx.shell`: the bash tools, hook commands, and background jobs run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load.
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner rejects with attributable `ENOENT` or `EACCES`, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests.
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. An attributable `ENOENT` or `EACCES` that names the selected runner proves that executable did not start, so the consumer reports the same infrastructure error; other synchronous creation errors propagate unchanged. An asynchronous subprocess-provider rejection exposes no public target stage and retains stage-neutral local semantics. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The owner-derived pending policy context states the current file policy without replacing those enforcement boundaries. When `dsh-permission-presets` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP application bundle](../../../../packages/bundle/acp-app/README.md) does not mount that UI service and selects its deployment mode explicitly.
@@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La
#### The bash consumer
`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection 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 local command-start semantics. Foreground execution converts runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an 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 process starts, 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`, 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`.
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](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
@@ -184,7 +184,7 @@ Costs and accepted limits:
## FAQ
- **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background job stamps `sandbox.runnerFailed` and renders its own marker. 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 throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
- **What happens on a platform with no backend?** `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.
@@ -38,7 +38,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是
这一替换对 `ctx.shell` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 argv 启动。删除 `sandbox``permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此组合不完整的 preset 层会在加载时明确报错。
配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT``EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。
配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。可归因的 `ENOENT``EACCES` 指明所选 runner,就能证明该 executable 未启动,因此消费方会报告同一基础设施错误;其他同步创建错误原样传播。异步 subprocess-provider rejection 不公开 target 阶段,保留本地不声明阶段的语义`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACPAgent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。
@@ -70,7 +70,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness
#### bash 消费方
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT``EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall``'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true``denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`bash 生产者再通过通用 `job_output` 渲染它。
`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` 渲染它。
模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)负责该上下文的理由与边界。
@@ -184,7 +184,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
## FAQ
- **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。
- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT``EACCES` 时,才能据此判定 runner 损坏没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `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,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-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 8128e4466c0845ee9749c8bff7d8e982a9780133
2026-07-16-persistent-pty-sessions.zh.md: ad7ac3c4a51bd01fc20408197fdf857cac036e33
2026-07-16-persistent-pty-sessions.md: 276aa11dea77d43ea58109ef3f165d4f1c79640e
2026-07-16-persistent-pty-sessions.zh.md: f4dc8c6c1b207879ef1b4583277ef2080c94405b
@@ -92,7 +92,7 @@ Background sends use the existing task completion notice and `job_output` result
### Process-tree teardown
On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Close sends `SIGTERM` to the direct PTY and the scope, waits for the manager to prove that range empty, and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome.
On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Before establishment, close sends `SIGTERM` through the direct PTY fallback so the bootstrap cannot continue; after establishment, it signals the scope alone and uses the direct fallback only if scope signalling fails. It waits for the manager to prove that range empty and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome.
Fallback hosts retain observational process-session cleanup. The handle snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation.
@@ -92,7 +92,7 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此
### 进程树 teardown
在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。close 会向 direct PTY 与 scope 发送 `SIGTERM`,等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。
在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。建立前,close 通过 direct PTY fallback 发送 `SIGTERM`阻止 bootstrap 继续;建立后只向 scope 发送信号,并且仅在 scope signalling 失败时使用 direct fallback。随后它等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。
fallback 宿主保留观察式进程 session 清理。句柄会按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f
config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7
config-catalog.md: 8669904b4470cd2b7db09922e475d3aecbbc1dfb
config-catalog.zh.md: dd19ac6375034a74f01312c7237e7c8cfa0452e3
+1 -1
View File
@@ -311,7 +311,7 @@ export type Config = LocalConfig
Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local)
Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts)
Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
<a id="deepseek-aidsh-client-connection"></a>
+1 -1
View File
@@ -313,7 +313,7 @@ export type Config = LocalConfig
依赖:[`LocalConfig`](#deepseek-aidsh-bash-local)
来源:[`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts)
来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
<a id="deepseek-aidsh-client-connection"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/shell.md
shell.md: 554adcfb1b37a5e2fe787a78dbb61a1206f80cf9
shell.zh.md: f15b57cc0050bb8f38d2652ddb9ad0e250568663
shell.md: ff83ae6d6e1b13e53e5f1112b2d713d3212a132e
shell.zh.md: 60beba0ef37129a5bd3f86922f4beeef68e94010
+5 -2
View File
@@ -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 process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
`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.
```ts type-equiv
/**
@@ -182,7 +182,10 @@ interface ShellProcess {
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
/**
* Resolves when the underlying process settles (never rejects — provider
* rejection settles as `killed` with a stage-neutral error on stderr).
*/
readonly done: Promise<void>
/** Sandbox facts, stamped once a confined process settles. */
sandbox?: ShellSandboxInfo
+5 -2
View File
@@ -166,7 +166,7 @@ interface ShellSandboxInfo {
## 后台进程:`ShellProcess`
`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时完成且绝不被拒绝;进程结后仍可读取,并且沙箱事实会在 `done` 完成前写入。
`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 会在底层进程结算时完成且绝不 rejectsubprocess 提供方的 rejection 会生成状态为 `killed` 的进程,并把不声明阶段的错误写入 stderr。进程结后仍可读取,并且沙箱事实会在 `done` 完成前写入。
```ts type-equiv
/**
@@ -182,7 +182,10 @@ interface ShellProcess {
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
/**
* Resolves when the underlying process settles (never rejects — provider
* rejection settles as `killed` with a stage-neutral error on stderr).
*/
readonly done: Promise<void>
/** Sandbox facts, stamped once a confined process settles. */
sandbox?: ShellSandboxInfo
@@ -201,9 +201,10 @@ export function resolveRgPath(): Promise<string> {
* `SEARCH_INVALID_PATTERN`, the rest `SEARCH_FAILED` /
* `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are
* classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort
* racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a
* rejection of `handle.done` (the seam's infrastructure failures) both become
* `SEARCH_FAILED` with the original as `cause` an abort already observed by
* racing the pre-check, a rejected `@vscode/ripgrep` resolution) reports that
* the command could not start, while a rejection of `handle.done` reports a
* provider failure without claiming whether execution began. Both become
* `SEARCH_FAILED` with the original as `cause`; an abort already observed by
* creation time becomes `SEARCH_ABORTED` instead.
*
* @param ctx - the plugin context; execution uses its `subprocess` service.
@@ -258,7 +259,7 @@ export async function runRipgrep(
try {
outcome = await handle.done
} catch (error: unknown) {
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
throw new SearchError(`${toolName} subprocess failed before reporting an outcome (ripgrep provider failure)`, 'SEARCH_FAILED', { cause: error })
}
const stdout = handle.collected.stdout?.readFrom(0)
const stderr = handle.collected.stderr?.readFrom(0)
@@ -189,12 +189,13 @@ describe('search tools over the real subprocess service + the packaged rg', () =
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
})
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
it('an unusable session cwd provider rejection is SEARCH_FAILED', async () => {
const gone = join(dir, 'deleted-session-dir')
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
expect(text(result)).toContain('subprocess failed before reporting an outcome')
expect(text(result)).not.toContain('could not start')
})
})
})
@@ -471,10 +471,9 @@ describe('workdir derivation and signal forwarding', () => {
.toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
})
it('translates a spawn rejection into SEARCH_FAILED even when the signal aborts concurrently', async () => {
// The seam rejects only for infrastructure failures (unusable workdir,
// missing binary); the abort happened after dispatch, so the launch
// failure is the reportable cause with the original error chained.
it('translates a provider rejection without claiming the search command never started', async () => {
// The public seam does not expose whether a done rejection happened before
// or after target execution; the original provider error remains chained.
const { ctx, subprocess } = await setup()
const controller = new AbortController()
subprocess.handler = () => {
@@ -486,7 +485,8 @@ describe('workdir derivation and signal forwarding', () => {
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
expect(text(result)).toContain('subprocess failed before reporting an outcome')
expect(text(result)).not.toContain('could not start')
})
it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/bash-local/README.md
README.md: 1523430aa27c5b07d6dd1db614a697a1c1738cc2
README.zh.md: dc5200e764623f8482b9e07f21bed300f0895c2e
README.md: 388374217ed14ab2c26c3f7d50ab03a5ca57b234
README.zh.md: f0ef6b257943effaa265eaa81b682d2fd9ecd476
+1 -1
View File
@@ -137,7 +137,7 @@ These limits define when this executor is a poor fit. They are current package c
- **Unconfined by itself** — commands run with the harness process's authority; deployments needing confinement compose `dsh-bash-sandbox`, while per-call allow/deny/ask policy belongs on the tools' `pre-execute` waterfall.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
- **POSIX-only** — the `bash` binary is hardcoded and the underlying service's group semantics are POSIX; Windows is unsupported.
- **A background spawn-failure note is single-delivery**the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **A background provider-failure note is single-delivery**`SubprocessHandle.done` can reject before or after target execution begins, so the executor injects the stage-neutral `subprocess failed before reporting an outcome: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
<a id="dev-note"></a>
### Dev Note
+1 -1
View File
@@ -137,7 +137,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
- **自身不提供隔离**——命令以 harness 进程的权限运行;需要隔离的部署组合 `dsh-bash-sandbox`,每次调用的 allow/deny/ask 策略则属于工具的 `pre-execute` waterfall。
- **没有持久 shell 或 PTY**——每次调用都启动全新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续延期,直到真实工作流需要它们。
- **仅支持 POSIX**——`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
- **后台 spawn 失败提示只交付一次**——subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
- **后台 provider failure 提示只交付一次**——`SubprocessHandle.done` 可能在 target 开始执行前或后 reject,因此执行器把不声明失败阶段的 `subprocess failed before reporting an outcome: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
<a id="dev-note"></a>
### 开发备注
+15 -17
View File
@@ -252,19 +252,19 @@ export class LocalBashExecutor extends ShellExecutor {
* execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the live background handle; spawn rejection settles it as killed.
* @returns the live background handle; provider rejection settles it as killed.
*/
protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
// to buffer; the note is delivered exactly once through the read path.
let spawnFailureNote: string | undefined
const consumeSpawnFailure = (): string => {
const note = spawnFailureNote ?? ''
spawnFailureNote = undefined
// A provider rejection has no direct outcome to display; its stage is not
// public, so a neutral note is delivered once through the read path.
let providerFailureNote: string | undefined
const consumeProviderFailure = (): string => {
const note = providerFailureNote ?? ''
providerFailureNote = undefined
return note
}
@@ -283,10 +283,10 @@ export class LocalBashExecutor extends ShellExecutor {
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
// Background provider failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote, true, error)
providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}`
this.onProcessDone(proc, providerFailureNote, true, error)
}),
readOutput: (): ShellProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -294,9 +294,7 @@ export class LocalBashExecutor extends ShellExecutor {
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
// A failed spawn never produced process output, so the note and real
// stderr text are mutually exclusive.
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
const errText = err.text.length > 0 ? err.text : consumeProviderFailure()
// Single newline between sections: stdout chunks usually end with one
// already; add it only when missing.
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
@@ -321,15 +319,15 @@ export class LocalBashExecutor extends ShellExecutor {
/**
* Settlement hook for subclasses that attach execution facts to a process.
* Called after exit facts or spawn-failure output are stamped and before
* Called after exit facts or provider-failure output are stamped and before
* {@link ShellProcess.done} resolves. The base implementation is intentionally
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
* @param _providerRejected - whether the subprocess promise rejected without a direct outcome.
* @param _providerError - the provider rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {}
}
export default LocalBashExecutor
@@ -1,10 +1,11 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
@@ -288,13 +289,36 @@ describe('LocalBashExecutor.start (background process handles)', () => {
expect(proc.signal).toBe('SIGTERM')
})
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
it('an asynchronous provider rejection does not claim that the command never started', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
done: Promise.reject(new Error('provider lost the direct outcome')),
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const proc = bash.start(bash.resolve({ command: 'true' }))
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
const output = proc.readOutput().delta
expect(output).toContain('subprocess failed before reporting an outcome:')
expect(output).not.toContain('spawn failed:')
})
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' }))
// done resolves (never rejects) even though the process never ran.
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
expect(proc.readOutput().delta).toContain('spawn failed:')
expect(proc.readOutput().delta).toContain('subprocess failed before reporting an outcome:')
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/bash-sandbox/README.md
README.md: 8ec918ed3e361f6a2c6345d1cda25e5c0bd39560
README.zh.md: 4c44a0ad6d09d16c18f75fe237f91116f7a62fcd
README.md: 3eef8b4b92f857a643e910090fe83333fdedac03
README.zh.md: 6b2b14026dc9f159847223968993e4a0dcd95696
+2 -2
View File
@@ -61,7 +61,7 @@ A denied command is reported, not retried silently: the result carries `sandbox:
### Failures and recovery
If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A runner-attributable spawn failure carries the original spawn error as detail; other spawn rejections keep the local executor's ordinary command-start semantics.
If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A provider rejection is attributed to the confinement runner only when its `ENOENT`/`EACCES` path or syscall independently names `argv[0]`; otherwise it keeps the local executor's stage-neutral provider-failure semantics.
-----
@@ -151,7 +151,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the `SANDBOX_UNAVAILABLE` error from the sandbox seam. A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names `argv[0]` remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection; the appended `Runner failure: <detail>` is the authoritative diagnosis over the generic `SANDBOX_UNAVAILABLE` prefix.
If no runner can enforce a confined mode, the foreground call propagates the `SANDBOX_UNAVAILABLE` error from the sandbox seam. A provider rejection with `ENOENT`/`EACCES` path or syscall evidence that names `argv[0]` supplies the original error as runner-failure detail; another rejection remains a stage-neutral provider error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection; the appended `Runner failure: <detail>` is the authoritative diagnosis over the generic `SANDBOX_UNAVAILABLE` prefix.
#### Token effect
+2 -2
View File
@@ -61,7 +61,7 @@ kind: "package-reference"
### 失败与恢复
如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;其他 spawn 拒绝保持本地执行器普通的命令启动语义。
如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。只有当 provider rejection 的 `ENOENT`/`EACCES` 路径或 syscall 独立指向 `argv[0]` 时,才把它归因于 confinement runner;其他 rejection 保持本地执行器不声明阶段的 provider-failure 语义。
-----
@@ -151,7 +151,7 @@ kind: "package-reference"
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播来自 sandbox seam 的 `SANDBOX_UNAVAILABLE` 错误。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;没`ENOENT`/`EACCES` `path``syscall` 证据指明 `argv[0]`拒绝仍是普通的命令启动错误。已结算的 runner 失败以匹配到的致命 stderr 行作为详情,并保留原始 stderr 收集结果;追加的 `Runner failure: <detail>` 是权威诊断,优先于通用的 `SANDBOX_UNAVAILABLE` 前缀。
如果没有 runner 能强制执行受限模式,前台调用会传播来自 sandbox seam 的 `SANDBOX_UNAVAILABLE` 错误。`ENOENT`/`EACCES` 路径或 syscall 证据并指向 `argv[0]` provider rejection 会把原始错误作为 runner-failure 详情;其他 rejection 保持不声明阶段的 provider error。已结算的 runner 失败以匹配到的致命 stderr 行作为详情,并保留原始 stderr 收集结果;追加的 `Runner failure: <detail>` 是权威诊断,优先于通用的 `SANDBOX_UNAVAILABLE` 前缀。
#### Token 影响
+12 -10
View File
@@ -1,10 +1,11 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* mode, enforcement, and denial facts. Positive runner-executable evidence
* identifies a broken confinement runner: foreground calls throw
* `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`;
* other provider rejections retain stage-neutral local-executor semantics. The
* tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -147,14 +148,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
// A provider rejection exposes no public failure stage. Attribute it to
// the confinement runner only when the error independently names argv[0].
// Otherwise settled runner failure outranks denial-like diagnostics.
const runnerFailed = providerRejected
? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
@@ -163,7 +165,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
super.onProcessDone(proc, stderr, providerRejected, providerError)
}
/**
@@ -100,7 +100,7 @@ describe('partial Landlock runner-failure classification', () => {
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner}`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
@@ -134,7 +134,7 @@ describe('partial Landlock runner-failure classification', () => {
const task = bash.start(bash.resolve(request))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
@@ -188,7 +188,7 @@ describe('partial Landlock runner-failure classification', () => {
const output = background.readOutput().delta
expect(output.startsWith('[stderr]\n')).toBe(true)
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
expect(output).not.toContain('spawn failed:')
expect(output).not.toContain('subprocess failed before reporting an outcome:')
}
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
@@ -547,7 +547,7 @@ describe('background sandbox facts', () => {
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
@@ -560,7 +560,7 @@ describe('background sandbox facts', () => {
}
})
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
it('does not invent runner evidence when a provider rejection has no structured reason', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
@@ -570,7 +570,7 @@ describe('background sandbox facts', () => {
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
// Arbitrary subprocess providers can reject without a value or public stage.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
done: Promise.reject(undefined),
terminate: vi.fn(),
@@ -580,7 +580,7 @@ describe('background sandbox facts', () => {
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.readOutput().delta).toContain('spawn failed: undefined')
expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome: undefined')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/pwsh-local/README.md
README.md: 0f586495e9ab9c1bb533d2dce26f2777ef859bc8
README.zh.md: e704ee6d2f55fd72143b1c50da4b7b0162ff830e
README.md: 275a5a0bcda3ddc663b989f778f6cd69d19470b9
README.zh.md: 93f81526e8b0911f331254324eafce19cdd78c42
+1 -1
View File
@@ -143,7 +143,7 @@ These limits define when this executor is a poor fit. They are current package c
- **Unconfined by itself** — commands run with the harness process's authority; deployments needing confinement compose a sandboxing executor or policy instead.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`.
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
- **A background spawn-failure note is single-delivery**the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **A background provider-failure note is single-delivery**`SubprocessHandle.done` can reject before or after target execution begins, so the executor injects the stage-neutral `subprocess failed before reporting an outcome: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; wrap a `param(...)` script in `& { … }`, and run `using`/`#requires` scripts from a file instead.
- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws; pwsh 7 defaults to UTF-8 and is unaffected.
+1 -1
View File
@@ -143,7 +143,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
- **自身不提供隔离**——命令以 harness 进程的权限运行;需要隔离的部署组合沙箱执行器或策略。
- **没有持久 shell 或 PTY**——每次调用都启动全新的 `pwsh -Command`
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
- **后台 spawn 失败提示只交付一次**——subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
- **后台 provider failure 提示只交付一次**——`SubprocessHandle.done` 可能在 target 开始执行前或后 reject,因此执行器把不声明失败阶段的 `subprocess failed before reporting an outcome: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结算,因此基于信号的状态分类在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`
- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)``#requires``using` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;`param(...)` 脚本请包进 `& { … }``using`/`#requires` 脚本请改从文件运行。
- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常;pwsh 7 默认 UTF-8,不受影响。
+13 -15
View File
@@ -288,12 +288,12 @@ export class PwshLocalExecutor extends ShellExecutor {
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
const collected = PwshLocalExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
// to buffer; the note is delivered exactly once through the read path.
let spawnFailureNote: string | undefined
const consumeSpawnFailure = (): string => {
const note = spawnFailureNote ?? ''
spawnFailureNote = undefined
// A provider rejection has no direct outcome to display; its stage is not
// public, so a neutral note is delivered once through the read path.
let providerFailureNote: string | undefined
const consumeProviderFailure = (): string => {
const note = providerFailureNote ?? ''
providerFailureNote = undefined
return note
}
@@ -312,10 +312,10 @@ export class PwshLocalExecutor extends ShellExecutor {
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
// Background provider failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote, true, error)
providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}`
this.onProcessDone(proc, providerFailureNote, true, error)
}),
readOutput: (): ShellProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -323,9 +323,7 @@ export class PwshLocalExecutor extends ShellExecutor {
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
// A failed spawn never produced process output, so the note and real
// stderr text are mutually exclusive.
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
const errText = err.text.length > 0 ? err.text : consumeProviderFailure()
// Single newline between sections: stdout chunks usually end with one
// already; add it only when missing.
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
@@ -355,10 +353,10 @@ export class PwshLocalExecutor extends ShellExecutor {
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the spawn rejected before any process existed.
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
* @param _providerRejected - whether the subprocess promise rejected without a direct outcome.
* @param _providerError - the provider rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {}
}
/* jscpd:ignore-end */
@@ -18,7 +18,7 @@ import { Context } from '@deepseek-ai/cordis'
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SubprocessRuntime from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
@@ -155,6 +155,7 @@ describe('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessRuntime extends SubprocessRuntime {
specs: SubprocessSpawnSpec[] = []
done: Promise<SubprocessOutcome> = Promise.resolve({ exitCode: 0, signal: null })
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
@@ -167,7 +168,7 @@ describe('spawn construction (pure, every platform)', () => {
stdout: undefined,
stderr: undefined,
collected: { stdout: this.reader, stderr: this.reader },
done: Promise.resolve({ exitCode: 0, signal: null }),
done: this.done,
terminate: () => {},
waitForExit: async () => true,
}
@@ -186,6 +187,20 @@ describe('spawn construction (pure, every platform)', () => {
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
})
it('reports asynchronous provider rejection without claiming that pwsh never started', async () => {
const ctx = new Context()
const subprocess = new CapturingSubprocessRuntime(ctx)
await ctx.plugin(PwshLocalExecutor)
subprocess.done = Promise.reject(new Error('provider lost the direct outcome'))
const proc = 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
expect(output).toContain('subprocess failed before reporting an outcome:')
expect(output).not.toContain('spawn failed:')
})
})
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
@@ -435,13 +450,13 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal)
})
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
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' }))
// done resolves (never rejects) even though the process never ran.
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
expect(proc.readOutput().delta).toContain('spawn failed:')
expect(proc.readOutput().delta).toContain('subprocess failed before reporting an outcome:')
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md
README.md: 49cadd35a9f4311cfa4188df6e555e180e3486aa
README.zh.md: 20595f7f10c38a9bff77666dab014314895b97f7
README.md: d2aa0170e95e89fd4a53499dec8f641113f96cf8
README.zh.md: f691b6c589e663863a60d2a39bcc09bf3968389c
+1 -1
View File
@@ -61,7 +61,7 @@ A denied command is reported as a fact: the result carries `sandbox: { mode, den
### Failures and recovery
If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A runner-attributable spawn failure carries the original spawn error as detail; other spawn rejections keep the local executor's ordinary command-start semantics.
If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A provider rejection is attributed to the confinement runner only when its `ENOENT`/`EACCES` path or syscall independently names `argv[0]`; otherwise it keeps the local executor's stage-neutral provider-failure semantics.
-----
+1 -1
View File
@@ -61,7 +61,7 @@ kind: "package-reference"
### 失败与恢复
如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;其他 spawn 拒绝保持本地执行器普通的命令启动语义。
如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。只有当 provider rejection 的 `ENOENT`/`EACCES` 路径或 syscall 独立指向 `argv[0]` 时,才把它归因于 confinement runner;其他 rejection 保持本地执行器不声明阶段的 provider-failure 语义。
-----
+13 -12
View File
@@ -3,12 +3,12 @@
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
* chain), inherits local process mechanics, and reports the selected mode,
* enforcement, and denial facts. Positive runner-launch evidence means the
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool layer owns the escalation approval flow
* through `ctx.approval`; this executor reports the sandbox facts the tool
* renders.
* enforcement, and denial facts. Positive runner-executable evidence
* identifies a broken confinement runner: foreground calls throw
* `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`;
* other provider rejections retain stage-neutral local-executor semantics. The
* tool layer owns the escalation approval flow through `ctx.approval`; this
* executor reports the sandbox facts the tool renders.
* @module @deepseek-ai/dsh-pwsh-sandbox
*/
@@ -153,14 +153,15 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
* Stamp per-process sandbox facts before `done` settles. Full-access
* processes have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
// A provider rejection exposes no public failure stage. Attribute it to
// the confinement runner only when the error independently names argv[0].
// Otherwise settled runner failure outranks denial-like diagnostics.
const runnerFailed = providerRejected
? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
@@ -169,7 +170,7 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
super.onProcessDone(proc, stderr, providerRejected, providerError)
}
/**
@@ -300,7 +300,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 30_000)
it('background spawn rejections settle as runnerFailed facts', async () => {
it('background provider rejections with runner provenance settle as runnerFailed facts', async () => {
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
@@ -312,7 +312,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
// The failure note surfaces through the read path.
const read = proc.readOutput()
expect(read.delta).toContain('spawn failed')
expect(read.delta).toContain('subprocess failed before reporting an outcome')
}, 30_000)
it('danger-full-access background runs bypass confine and carry no facts', async () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/shell/README.md
README.md: 1400947fc7cda0d45a050e45008356aebbd33776
README.zh.md: bde496672866bb182f883d0fe0d8d793624cfa49
README.md: 0c1cb0872b32be55f5d5b617091b8acc39d64b2b
README.zh.md: 3e2fe2a7786a0195cd13c59f8118e2a63147ed40
+1 -1
View File
@@ -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 (spawn failures settle as `killed` with the 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` 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.
</details>
+1 -1
View File
@@ -91,7 +91,7 @@ seam 本身不是执行器:每个组合只挂载一个提供方,工具即可
### 后台生命周期与归属
后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject`start` 立即返回且不设超时,其 `done` 绝不 rejectspawn 失败`killed` 结算,错误入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。
后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject`start` 立即返回且不设超时,其 `done` 绝不 rejectsubprocess provider rejection `killed` 结算,并把不声明阶段的错误入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。
</details>
+4 -1
View File
@@ -165,7 +165,10 @@ export interface ShellProcess {
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
/**
* Resolves when the underlying process settles (never rejects provider
* rejection settles as `killed` with a stage-neutral error on stderr).
*/
readonly done: Promise<void>
/** Sandbox facts, stamped once a confined process settles. */
sandbox?: ShellSandboxInfo
@@ -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/subprocess/subprocess-local/README.md
README.md: 4fcc0812819f2960d9cacd3ef07b1e47da4126e1
README.zh.md: c5b36637dca64587981aa6a349c9bf85cfb6acba
README.md: 89817f20bf1eebc0692af45a164be0e8cb468168
README.zh.md: 7df61f1894a22c5d00ac1c8528cec46033ce7c66
@@ -54,7 +54,7 @@ Normal disposal terminates every running managed range and terminal session and
### What can go wrong
An executable that cannot be resolved fails loud with a stable error; a spawn that never starts rejects `done`. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below.
An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below.
-----
@@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
- **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
- **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
- **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership.
@@ -54,7 +54,7 @@ kind: "package-reference"
### 可能出错的地方
无法解析的可执行文件会以稳定错误快速失败;从未启动成功的 spawn 会让 `done` reject。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。
无法解析的可执行文件会以稳定错误快速失败。当 spawn 或 provider failure 使 direct outcome 无法产生时,`done` reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。
-----
@@ -130,7 +130,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。
- **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
- **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
- **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F``taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。
- **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。
@@ -38,6 +38,7 @@ export interface LinuxScopeInternals {
resolveRunnerInvocation?: () => RunnerInvocation
runnerAvailable?: (invocation: RunnerInvocation) => boolean
loadLinuxExecve?: typeof loadLinuxExecve
sleep?: (delayMs: number) => Promise<void>
}
interface SystemctlResult {
@@ -48,7 +49,7 @@ interface SystemctlResult {
}
const SYSTEMCTL_TIMEOUT_MS = 5_000
const SCOPE_POLL_INTERVAL_MS = 50
const SCOPE_INITIAL_POLL_INTERVAL_MS = 50
const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
function systemctlEnv(): NodeJS.ProcessEnv {
@@ -146,6 +147,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
private readonly systemctl: string,
private readonly runSync: typeof spawnSync,
private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
private readonly sleep: (delayMs: number) => Promise<void>,
) {}
signal(signal: 'SIGTERM' | 'SIGKILL'): void {
@@ -228,7 +230,15 @@ class SystemdScopeOwner implements BoundProcessOwner {
async waitForExit(): Promise<void> {
if (this.stopped) return
this.observation ??= (async () => {
while (await this.rangeActive()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS
while (await this.rangeActive()) {
await this.sleep(pollIntervalMs)
// Keep establishment responsive, then reduce systemctl process churn
// while systemd remains the authoritative owner of an active range.
if (this.established) {
pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS)
}
}
this.stopped = true
})().catch((error: unknown) => {
this.observation = undefined
@@ -337,6 +347,7 @@ export function prepareLinuxTerminalScope(
internals.systemctl ?? 'systemctl',
internals.spawnSync ?? spawnSync,
internals.systemctlQuery ?? querySystemctl,
internals.sleep ?? sleepMs,
),
resolveOutcome: (outcome) => {
const startup = readLinuxStartupError(files.startupErrorPath)
@@ -391,6 +402,7 @@ export function launchLinuxScope(
internals.systemctl ?? 'systemctl',
internals.spawnSync ?? spawnSync,
internals.systemctlQuery ?? querySystemctl,
internals.sleep ?? sleepMs,
)
return {
stdin: child.stdin,
@@ -105,6 +105,7 @@ function launch(
runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'],
...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable },
...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve },
...overrides.sleep === undefined ? {} : { sleep: overrides.sleep },
})
const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('launch did not publish a request locator')
@@ -232,6 +233,30 @@ describe('Linux scope establishment and quiescence', () => {
result.owner.cleanup?.()
})
it('polls promptly before establishment and backs off established active scopes', async () => {
const delays: number[] = []
const states = [
missingUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit(),
activeUnit('inactive'),
]
const launched = launch(
async () => states.shift() ?? activeUnit('inactive'),
{ sleep: async (delayMs) => { delays.push(delayMs) } },
)
await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000])
launched.result.owner.cleanup?.()
})
it('reports child termination before request consumption to both result and wait', async () => {
const { child, result } = launch(async () => missingUnit())
child.exit(127, null)