Merge remote-tracking branch 'origin/master' into fix/pty-handoff-grace

This commit is contained in:
Chinesezjc
2026-07-27 20:20:28 +08:00
191 changed files with 3897 additions and 2391 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25
README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1
README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe
README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5

View File

@@ -13,6 +13,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |

View File

@@ -13,6 +13,7 @@
| [`core/`](core/README.md) | 产品 API 主干会话、提示词、工具、agent智能体服务与具体循环 | 产品:稳定表面 |
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |

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
README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060
README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db

View File

@@ -6,8 +6,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent
| Package | Role | ctx key |
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |

View File

@@ -6,8 +6,8 @@
| 包 | 职责 | ctx key |
|---|---|---|
| `bash/` | 抽象 bash 执行器 seam接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` |
| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash` |
| `bash/` | 抽象 bash 执行器 seam接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出 | `ctx.bash` |
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现命令默认值补全、deadline、终端环境、后台读取合并 | (注册 `ctx.bash` |
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv标记拒绝强制执行事实扩展 `bash-local` 的机制) | (注册 `ctx.bash` |
| `tool-bash/` | 面向模型的 `bash` schema后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools` |

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
README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c
README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
## Config
@@ -24,11 +24,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience
@@ -42,8 +42,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
- **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, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
- **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.
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c <command>`,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose资源释放归进程管理器服务所有
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`
## 配置
@@ -24,11 +24,11 @@
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流程需要时采用。
- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn拥有独立进程组终止时先向该组发送 SIGTERM经过 `graceMs` 宽限期后再发送 SIGKILL默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同
- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件
- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回实时 `BashProcess` 句柄不应用超时Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 终止每个运行中的进程并等待退出。所有具有任务形态的事项id、所有权、轮询、通知都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流程需要时采用。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)
- **适合模型的终端环境**设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回实时 `BashProcess` 句柄不应用超时Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 终止等待退出。所有具有任务形态的事项id、所有权、轮询、通知都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
## 模型体验
@@ -42,8 +42,7 @@
- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。
- **仅支持 POSIX**`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。
- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
- **仅支持 POSIX**`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它
原始进程处理位于 `src/run.ts``src/index.ts` 负责服务接线
凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有

View File

@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,6 +39,8 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -1,17 +1,39 @@
/**
* Local-subprocess implementation of the bash executor seam. Each command runs
* as `bash -c` in its own process group; disposal kills and joins live groups.
* Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
* Local implementation of the bash executor seam over the subprocess
* seam. Each command runs as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
* and cause classification, the model-friendly terminal environment, and the
* model-facing stdout/stderr merge for background reads. Execution policy
* belongs in `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
/**
* Model-friendly environment overrides: disable colors, pagers, and
* interactive terminal features that would garble tool output (the same set
* Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
* merged first into the spawn's explicit env, so a trusted caller's own entry
* still wins; the subprocess service applies its credential scrub independently.
*/
export const ENV_OVERRIDES = {
NO_COLOR: '1',
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
} as const
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
const DEFAULT_GRACE_MS = 3_000
/** Default per-stream spill cap (the `maxSpillBytes` config). */
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
@@ -32,6 +54,16 @@ export interface Config {
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
const read = reader.readFrom(0)
return {
text: read.text,
truncated: read.lossy,
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
}
}
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`bash-local: ${name} must be a positive finite number`)
@@ -39,10 +71,15 @@ function assertPositiveFinite(name: string, value: number): void {
}
/**
* Local bash executor with bounded output, spill files, and process-group
* `SIGTERM` to `SIGKILL` escalation.
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERMSIGKILL escalation are the subprocess service's
* mechanics; this executor supplies their configured budgets per spawn, so a
* still-running background process stays managed (killed and joined at
* composition teardown) even across an executor reload.
*/
export class LocalBashExecutor extends BashExecutor {
static inject = ['subprocess']
static Config: z<Config> = z.object({
cwd: z.string(),
timeoutMs: z.number().default(120_000),
@@ -52,11 +89,6 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
/** Live processes retained only so disposal can kill and join them. */
private live = new Map<BashProcess, RunningBash>()
/** Test seam: spill knobs forwarded to runBash. */
internals: RunInternals = {}
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
@@ -69,17 +101,6 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Await closure so even a TERM-trapping child cannot outlive the fiber.
const pending: Promise<void>[] = []
for (const [proc, running] of this.live) {
proc.status = 'killed'
running.kill()
pending.push(proc.done)
}
this.live.clear()
await Promise.all(pending)
}, 'local bash teardown')
}
/**
@@ -105,7 +126,7 @@ export class LocalBashExecutor extends BashExecutor {
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
// no config default. run.ts owns the scrub and merge order.
// no config default. The subprocess service owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
@@ -116,41 +137,71 @@ export class LocalBashExecutor extends BashExecutor {
}
}
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: ['bash', '-c', spec.command],
cwd: spec.workdir,
stdio: {
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
stdout: collect(stdoutMaxBytes),
stderr: collect(this.config.maxOutputBytes),
},
graceMs: this.config.graceMs,
signal,
// One explicit env map for the seam, layered so the trusted dshEnv
// snapshot beats both the caller's env and the terminal overrides; the
// subprocess service merges the whole map after its ambient scrub.
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
}
}
/** The collect-mode readers the executor itself requested (present by construction). */
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
const { stdout, stderr } = handle.collected
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
if (stdout === undefined || stderr === undefined) {
throw new Error('bash-local: subprocess implementation dropped a requested collect stream')
}
/* v8 ignore stop */
return { stdout, stderr }
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
stdoutMaxBytes: spec.stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals).done
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
return {
...outcome,
timedOut,
aborted,
timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout),
stderr: finalOutput(collected.stderr),
}
}
start(spec: BashExecSpec): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = runBash({
command: spec.command,
cwd: spec.workdir,
stdoutMaxBytes: this.config.maxOutputBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals)
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, 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
return note
}
let stdoutOffset = 0
let stderrOffset = 0
@@ -165,26 +216,27 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote)
}),
readOutput: (): BashProcessRead => {
const out = running.stdout.readFrom(stdoutOffset)
const err = running.stderr.readFrom(stderrOffset)
const out = collected.stdout.readFrom(stdoutOffset)
const err = collected.stderr.readFrom(stderrOffset)
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
// A failed spawn never produced process output, so the note and real
// stderr text are mutually exclusive.
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
// Single newline between sections: stdout chunks usually end with one
// already; add it only when missing.
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
const delta = out.text
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
return {
delta,
lossy: out.lossy || err.lossy,
@@ -195,11 +247,10 @@ export class LocalBashExecutor extends BashExecutor {
kill: (): boolean => {
if (proc.status !== 'running') return false
proc.status = 'killed'
running.kill()
running.terminate()
return true
},
}
this.live.set(proc, running)
return proc
}

View File

@@ -1,399 +0,0 @@
/**
* Process plumbing for the local bash executor: detached process-group spawn,
* tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer
* reacts to an abort signal; the executor owns deadlines and classifies causes.
* @module dsh-bash-local/run
*/
import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
/**
* Model-friendly environment overrides: disable colors, pagers, and
* interactive terminal features that would garble tool output (the same set
* Codex hardcodes; Claude Code achieves it via TERM=dumb).
*/
export const ENV_OVERRIDES = {
NO_COLOR: '1',
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
} as const
/**
* Credential-shaped env vars are NOT forwarded to commands (the harness's
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
* spill files). Same default pattern as Codex's env policy; a future config
* can whitelist specific vars when a workflow genuinely needs one.
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* Build a child environment from scrubbed ambient values, terminal overrides,
* ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
* names are removed; ordinary and managed entries reject the other channel's
* namespace before `dshEnv` merges last.
* @param extra - caller entries; `DSH_*` names are rejected.
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
*/
export function childEnv(
extra?: Readonly<Record<string, string>>,
dshEnv?: DshEnvironment,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
}
for (const key of Object.keys(extra ?? {})) {
if (key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
}
}
for (const key of Object.keys(dshEnv ?? {})) {
if (!key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
}
}
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
}
/** What to run and under which limits (resolved — no defaults in here). */
export interface SpawnSpec {
command: string
cwd: string
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
graceMs: number
/**
* Abort signal — kills the process group when it fires. The executor owns
* timing: `run()` passes a fused timeout/cancel deadline signal (see
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
* runBash only listens and kills; it does NOT classify why (the executor
* reads the signal's reason afterward).
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
* the model-facing `dsh-tool-bash` tool does not thread model input here.
*/
stdin?: string | undefined
/**
* Ordinary environment entries merged after the credential scrub and
* terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
*/
env?: Record<string, string> | undefined
/** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
dshEnv?: DshEnvironment | undefined
}
/**
* Raw outcome of one closed process (before result shaping). Deliberately
* carries NO timeout/cancel classification: runBash kills on abort but does not
* decide why — the executor's `run()`/`start()` reads the deadline signal it
* owns to classify `timedOut`/`aborted` (see the package README).
*/
export interface SpawnOutcome {
exitCode: number | null
signal: NodeJS.Signals | null
stdout: CollectedOutput
stderr: CollectedOutput
}
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
export interface RunInternals {
/** Directory for spill files (defaults to the OS temp dir). */
spillDir?: string
}
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
export const DEFAULT_GRACE_MS = 3_000
/** Default per-stream spill cap (the `maxSpillBytes` config). */
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
let spillCounter = 0
let defaultSpillDir: string | undefined
/**
* The default spill location: a private (0700) per-process directory under
* the OS tmpdir, created lazily. Predictable world-readable paths would let
* other local users read command output or pre-create symlinks.
*/
function privateSpillDir(): string {
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
return defaultSpillDir
}
/**
* Collects one stream with a bounded in-memory tail. On first overflow a
* spill file is created and every chunk (including those already collected)
* is appended there while the full stream remains within `maxSpillBytes`.
*
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
* end of command output; the spill file covers the head.
*/
export class OutputCollector {
private chunks: Buffer[] = []
private bytes = 0
private dropped = false
private spillFd: number | undefined
private spillFile: string | undefined
private spillDisabled = false
/** Total bytes ever pushed (not just retained). */
private total = 0
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number,
private readonly label: string,
private readonly spillDir: string,
) {}
/**
* Ingest one stream chunk, counting it toward the whole-stream total. On
* first overflow of the in-memory cap a spill file is opened and every chunk
* (already-collected ones included) is appended there from then on; the
* in-memory tail then drops whole chunks from its head (or the head of a
* single over-cap chunk) until it fits the cap again.
* @param chunk - the raw bytes from one stream 'data' event.
*/
push(chunk: Buffer): void {
this.total += chunk.length
const overflows = this.bytes + chunk.length > this.maxBytes
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
// Drop whole chunks from the head; pipe chunks are small (≤64KiB), so
// the retained tail tracks the cap closely enough for a model-facing
// truncation boundary. (length > 1 was just checked — shift() returns.)
const head = this.chunks.shift() as Buffer
this.bytes -= head.length
this.dropped = true
}
if (this.bytes > this.maxBytes && this.chunks.length === 1) {
// A single chunk larger than the cap: keep its tail.
const only = this.chunks[0] as Buffer
this.chunks[0] = only.subarray(only.length - this.maxBytes)
this.bytes = this.maxBytes
this.dropped = true
}
}
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
private spillAll(chunk: Buffer): void {
if (this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
// prediction and symlink planting in shared tmp dirs.
this.spillFile = join(
this.spillDir,
`dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
for (const prior of this.chunks) writeSync(this.spillFd, prior)
}
writeSync(this.spillFd, chunk)
}
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
private discardSpill(): void {
const fd = this.spillFd
const file = this.spillFile
this.spillFd = undefined
this.spillFile = undefined
this.spillDisabled = true
if (fd !== undefined) {
try {
closeSync(fd)
} catch {
// Retain the descriptor so finalize can retry the failed close.
this.spillFd = fd
}
}
if (file !== undefined) {
try {
unlinkSync(file)
} catch {
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
}
}
}
/**
* Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the
* in-memory tail window, the read is `lossy` — it returns the whole
* retained tail and the gap is only recoverable from the spill file.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
*/
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
const windowStart = this.total - this.bytes
const buffer = Buffer.concat(this.chunks)
const lossy = fromByte < windowStart
const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
return {
text: slice.toString('utf8'),
nextOffset: this.total,
lossy,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
/**
* Close the spill file (if any) and return the final output. A failed close
* (delayed writeback fault) stops advertising the spill path — the file may
* be missing its tail — but still returns the in-memory result.
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
*/
finalize(): CollectedOutput {
if (this.spillFd !== undefined) {
try {
closeSync(this.spillFd)
} catch {
// A delayed writeback failure makes the spill unreliable; keep finalize
// total but stop advertising that file.
this.spillFile = undefined
}
this.spillFd = undefined
}
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
}
/**
* Send `sig` to a detached process group. Never throws: delivery races process
* exit and may run in a timer callback, so failures are contained and a
* non-positive pid is a no-op.
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
* @param sig - the signal to deliver to the whole group.
*/
export function killGroup(pid: number, sig: NodeJS.Signals): void {
if (pid <= 0) return
try {
process.kill(-pid, sig)
} catch {
// Swallow: see contract above.
}
}
/**
* A live bash child process: the promise resolves when the process closes;
* `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
*/
export interface RunningBash {
/** Process id (group leader); -1 when the spawn itself failed. */
readonly pid: number
/** stdout/stderr collectors (live — background polling reads incrementally). */
readonly stdout: OutputCollector
readonly stderr: OutputCollector
/** Resolves when the process closes; rejects only for spawn-level failures. */
readonly done: Promise<SpawnOutcome>
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
kill(): void
}
/**
* Spawn one isolated `bash -c` process group and collect its output.
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
* @param spec - fully resolved command, cwd, limits, and cancellation.
* @param internals - test-only process and spill-directory overrides.
* @returns live process handle and outcome promise.
*/
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const spillDir = internals.spillDir ?? privateSpillDir()
if (spec.signal?.aborted) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env, spec.dshEnv)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
let graceTimer: NodeJS.Timeout | undefined
// Failed spawns use pid -1 so kill remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
killGroup(pid, 'SIGTERM')
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
// The executor owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Stdin writes are best-effort; process exit and captured output remain authoritative.
if (child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
}
const done = new Promise<SpawnOutcome>((resolve, reject) => {
let settled = false
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
child.stdout.destroy()
child.stderr.destroy()
cleanup()
resolve({
exitCode,
signal,
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
}
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
settled = true
cleanup()
reject(error)
})
child.on('exit', (exitCode, signal) => {
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
if (graceTimer !== undefined) clearTimeout(graceTimer)
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})
return { pid, stdout, stderr, done, kill }
}

View File

@@ -4,16 +4,18 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
// A short kill grace via the REAL config path, so escalation tests stay fast.
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
return { ctx, bash }
}
@@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => {
})
})
describe('LocalBashExecutor disposal', () => {
it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
describe('process lifecycle ownership (the subprocess service, not the executor)', () => {
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const managerFiber = await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
// The child prints its own pid ($$ = the detached bash group leader) so
// the test can probe liveness through the public read surface alone.
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
const pid = Number((await readUntil(proc, '\n')).trim())
expect(Number.isInteger(pid) && pid > 0).toBe(true)
await fiber.dispose()
// Disposal itself waited: the pid must already be gone, no grace left —
// even for a TERM-trapping child held until the SIGKILL escalation landed.
// Executor reload/disposal leaves background work running — the
// handle stays live and readable, mirroring the task runtime's
// registrations-outlive-producer-fibers contract.
await executorFiber.dispose()
expect(proc.status).toBe('running')
expect(() => process.kill(pid, 0)).not.toThrow()
// Service disposal kills the group and AWAITS its exit (no orphans).
await managerFiber.dispose()
expect(() => process.kill(pid, 0)).toThrow()
expect(proc.status).toBe('killed')
await proc.done
expect(proc.status).toBe('killed')
})
it('settled processes already left the live map: dispose does not touch them', async () => {
it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const managerFiber = await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
const finished = bash.start(bash.resolve({ command: 'echo done' }))
await finished.done
expect(finished.status).toBe('completed')
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
await readUntil(trapping, 'armed')
await fiber.dispose()
// The teardown marks every LIVE entry killed; a settled process had
// already left the map, so its status stays completed.
await managerFiber.dispose()
// A settled process was untouched; the live one died by escalation.
expect(finished.status).toBe('completed')
expect(running.status).toBe('killed')
await running.done
expect(running.signal).toBe('SIGTERM')
await trapping.done
expect(trapping.status).toBe('killed')
expect(trapping.signal).toBe('SIGKILL')
})
})

View File

@@ -1,510 +0,0 @@
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
failNextUnlink: { value: false },
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
closeSync(fd: number): void {
if (failNextClose.value) {
failNextClose.value = false
throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
}
actual.closeSync(fd)
},
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
if (failNextUnlink.value) {
failNextUnlink.value = false
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
}
actual.unlinkSync(path)
},
}
})
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
return {
command,
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
maxSpillBytes: 64 * 1024 * 1024,
graceMs: 3_000,
...overrides,
}
}
/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
process.kill(pid, 0)
} catch {
return
}
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
}
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.readFrom(0).text.includes(expected)) return
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
}
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const pid = Number(readFileSync(path, 'utf8').trim())
if (Number.isSafeInteger(pid) && pid > 0) return pid
} catch {
// The child shell has not written the pid file yet.
}
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('runBash', () => {
it('captures stdout on success', async () => {
const result = await runBash(spec('echo hello')).done
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.stdout.text).toBe('hello\n')
expect(result.stdout.truncated).toBe(false)
expect(result.stderr.text).toBe('')
})
it('captures stderr separately', async () => {
const result = await runBash(spec('echo oops >&2')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
expect(result.stderr.text).toBe('oops\n')
})
it('captures both streams', async () => {
const result = await runBash(spec('echo out; echo err >&2')).done
expect(result.stdout.text).toBe('out\n')
expect(result.stderr.text).toBe('err\n')
})
it('reports non-zero exit codes', async () => {
const result = await runBash(spec('exit 42')).done
expect(result.exitCode).toBe(42)
expect(result.signal).toBeNull()
})
it('applies model-friendly env overrides', async () => {
const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
expect(result.stdout.text).toBe('1/dumb/cat\n')
})
it('runs in the requested cwd', async () => {
const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
it('kills the process group with SIGTERM when the signal fires', async () => {
// runBash owns no timer: it kills on abort. The executor drives the timeout
// by firing this signal via a deadline (see executor.spec.ts); here we
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
expect(result.signal).toBe('SIGTERM')
expect(result.exitCode).toBeNull()
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
const result = await running.done
expect(result.signal).toBe('SIGKILL')
})
it('kills the whole process group (grandchildren die too)', async () => {
// The subshell writes the sleep's pid then waits on it; killing the
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
running.kill()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
await waitGone(grandchild)
})
it('aborts via AbortSignal mid-run', async () => {
const controller = new AbortController()
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
it('throws when the signal is already aborted before spawn', () => {
const controller = new AbortController()
controller.abort('too late')
expect(() => runBash(spec('echo hi', { signal: controller.signal })))
.toThrow(/aborted before spawn: too late/)
})
it('rejects with a spawn error for a nonexistent cwd', async () => {
await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
.rejects.toThrow(/ENOENT/)
})
it('kill() is idempotent (second call does not restart escalation)', async () => {
const running = runBash(spec('sleep 60'))
running.kill()
running.kill()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
it('bounds inherited-pipe draining after the shell exits', async () => {
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
const started = Date.now()
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const descendant = await waitForPidFile(pidFile)
try {
const result = await running.done
expect(Date.now() - started).toBeLessThan(1_000)
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('shell-done\n')
} finally {
process.kill(descendant, 'SIGKILL')
await waitGone(descendant)
}
})
})
describe('stdin and extra env (set by in-process plugins)', () => {
it('writes stdin to the command and closes it', async () => {
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hello from stdin\n')
})
it('a command that reads stdin sees EOF when none is supplied', async () => {
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
// output (it does NOT block).
const result = await runBash(spec('cat')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
})
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
expect(piped.stdout.text).toBe('socket\n')
})
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
})).done
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
// The handler swallows that write error and `done` reports the child's real exit.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
})
})
describe('output truncation and spill', () => {
it('applies stdout and stderr caps independently', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
stdoutMaxBytes: 500,
stderrMaxBytes: 100,
}),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
})
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(true)
expect(result.stdout.text.length).toBeLessThanOrEqual(500)
expect(result.stdout.text).toContain('line-0200')
expect(result.stdout.text).not.toContain('line-0001')
expect(result.stdout.spillPath).toBeDefined()
const full = readFileSync(result.stdout.spillPath!, 'utf8')
expect(full).toContain('line-0001')
expect(full).toContain('line-0200')
})
it('does not truncate output exactly at the cap', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text.length).toBe(500)
expect(result.stdout.spillPath).toBeUndefined()
})
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(failNextClose.value).toBe(false)
expect(result.exitCode).toBe(0)
expect(result.stdout.truncated).toBe(true)
expect(result.stdout.text).toContain('line-0200')
expect(result.stdout.spillPath).toBeUndefined()
})
})
describe('OutputCollector', () => {
it('keeps the tail of a single oversized chunk', () => {
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('0123456789abcdef'))
const out = collector.finalize()
expect(out.text).toBe('6789abcdef')
expect(out.truncated).toBe(true)
expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
})
it('readFrom returns increments and flags lossy reads', () => {
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('aaaaa'))
const first = collector.readFrom(0)
expect(first.text).toBe('aaaaa')
expect(first.lossy).toBe(false)
expect(first.nextOffset).toBe(5)
collector.push(Buffer.from('bbbbb'))
const second = collector.readFrom(first.nextOffset)
expect(second.text).toBe('bbbbb')
expect(second.lossy).toBe(false)
// Push enough to slide the window past the last offset.
collector.push(Buffer.from('c'.repeat(20)))
const third = collector.readFrom(second.nextOffset)
expect(third.lossy).toBe(true)
expect(third.text).toBe('c'.repeat(10))
expect(third.spillPath).toBeDefined()
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.readFrom(0).spillPath).toBeDefined()
failNextClose.value = true
let out: ReturnType<typeof collector.finalize>
expect(() => { out = collector.finalize() }).not.toThrow()
expect(failNextClose.value).toBe(false)
expect(out!.text).toBe('bbbb')
expect(out!.truncated).toBe(true)
expect(out!.spillPath).toBeUndefined()
})
it('discards a spill that exceeds its configured cap', () => {
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
collector.push(Buffer.from('c'))
collector.push(Buffer.from('dddd'))
const out = collector.finalize()
expect(out.text).toBe('dddd')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
expect(() => readFileSync(spillPath)).toThrow()
})
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
collector.push(Buffer.from('abcdefgh'))
const out = collector.finalize()
expect(out.text).toBe('efgh')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
})
it('contains cleanup failures while disabling an oversize spill', () => {
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
failNextClose.value = true
failNextUnlink.value = true
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
expect(failNextClose.value).toBe(false)
expect(failNextUnlink.value).toBe(false)
expect(collector.finalize().spillPath).toBeUndefined()
unlinkSync(spillPath)
})
})
describe('killGroup', () => {
it('ignores non-positive pids', () => {
expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
})
it('swallows ESRCH for vanished groups', async () => {
const running = runBash(spec('true'))
await running.done
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
})
})
describe('abort edge cases', () => {
it('reports a fallback reason for reason-less pre-aborted signals', () => {
// Real AbortControllers always set a DOMException reason; signal-like
// objects from other libraries may not — the fallback covers them.
const bare = {
aborted: true,
reason: undefined,
addEventListener() {},
removeEventListener() {},
} as unknown as AbortSignal
expect(() => runBash(spec('echo hi', { signal: bare })))
.toThrow(/aborted before spawn: aborted/)
})
it('reports the terminating signal of an externally self-killed command', async () => {
// runBash reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
const result = await runBash(spec('kill -TERM $$')).done
expect(result.signal).toBe('SIGTERM')
})
})
describe('environment and spill-file hardening', () => {
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
process.env.DSH_TEST_API_KEY = 'super-secret'
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
delete process.env.DSH_TEST_TOKEN
delete process.env.DSH_TEST_PLAIN
}
})
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
process.env.DSH_STALE = 'old-value'
try {
const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
})).done
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
} finally {
delete process.env.DSH_STALE
}
})
it('rejects DSH variables on the ordinary env channel', () => {
expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
})
it('rejects ordinary variables on the managed env channel', () => {
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
expect(() => runBash(spec('true', { dshEnv: invalid })))
.toThrow(/managed bash env.*PATH.*use env/)
})
it('creates spill files with owner-only permissions and random names', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
const path = result.stdout.spillPath!
expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
const mode = statSync(path).mode & 0o777
expect(mode).toBe(0o600)
})
it('defaults spills into a private per-process directory', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
).done
const dir = dirname(result.stdout.spillPath!)
expect(dir).toMatch(/dsh-bash-/)
const mode = statSync(dir).mode & 0o777
expect(mode).toBe(0o700)
})
it('killGroup never throws, even for EPERM-style failures', () => {
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
})
try {
expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
} finally {
spy.mockRestore()
}
})
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../bash/bash"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}

View File

@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",

View File

@@ -34,7 +34,7 @@ export type Config = LocalConfig
* mode; `result.sandbox` reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox', 'sandboxPolicy']
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
@@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
* exact `['bash', '-c', command]` argv this executor would spawn, get back
* the confined argv, and re-assemble it into the `exec …` command string
* the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
*/

View File

@@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless integration of the real provider and executor through public run/start paths. With
@@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
@@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -58,9 +59,10 @@ async function setup(
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.bash as SandboxBashExecutor
bash.internals = { spillDir }
return { ctx, bash, calls }
}

View File

@@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless macOS integration of the real provider and executor through public run/start paths.
@@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

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
README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370
README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339

View File

@@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
## Model Experience

View File

@@ -33,7 +33,7 @@
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
## 模型体验

View File

@@ -28,11 +28,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -43,7 +43,10 @@ declare module 'cordis' {
* failures settle as `killed` with the error on stderr.
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
* repeat output. Lossy reads report truncation and available spill files.
* - Disposal kills all running background processes and awaits their exit.
* - 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.
*/
export abstract class BashExecutor extends Service {
constructor(ctx: Context) {

View File

@@ -1,19 +1,17 @@
/**
* Execution types for the bash executor seam. Background task semantics belong
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles.
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The
* managed-environment and captured-output vocabulary is owned by the
* subprocess seam and re-exported here so bash consumers keep one import
* root.
* @module dsh-bash/types
*/
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
/** Trusted DeepSeek Harness variables for one bash execution. */
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
@@ -62,17 +60,18 @@ export interface BashExecRequest {
stdin?: string | undefined
/**
* Ordinary environment entries for the command, merged after the credential
* scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
* here. Set by in-process plugins (the hooks bridges set
* `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
* does not expose it as a parameter.
* scrub. Managed facts belong in {@link dshEnv}, which merges after this
* map, so an entry here can never displace one. Set by in-process plugins
* (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the
* model-facing bash tool does not expose it as a parameter.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution. Executors discard
* ambient `DSH_*` entries before merging this snapshot, so an unavailable
* current fact cannot inherit a stale value from the harness process, and
* reject non-`DSH_*` names supplied through this managed channel.
* Harness-owned `DSH_*` variables for this execution (typed to managed
* keys). Executors discard ambient `DSH_*` entries before merging this
* snapshot last, so an unavailable current fact cannot inherit a stale
* value from the harness process and a caller {@link env} entry cannot
* displace a managed one.
*/
dshEnv?: DshEnvironment | undefined
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
@@ -99,27 +98,17 @@ export interface BashExecSpec {
stdin?: string | undefined
/**
* Ordinary environment entries carried through from
* {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
* {@link BashExecRequest.env}; {@link dshEnv} still merges after them.
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
* ordinary extra environment.
*/
env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
/** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox policy; ignored by executors that do not confine. */
sandboxPolicy: SandboxExecutionPolicy | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */
export interface CollectedOutput {
/** Collected text — the TAIL of the stream when truncated. */
text: string
/** True when bytes were dropped from `text`. */
truncated: boolean
/** Path to a file holding the COMPLETE stream, when truncated and available. */
spillPath?: string
}
/** The outcome of one completed (or killed) foreground run. */
export interface BashRunResult {
/** Exit code; null when the process died from a signal. */
@@ -165,8 +154,9 @@ export interface BashProcessRead {
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
* only access path; buffered output remains readable after exit. Executor
* disposal kills running processes and awaits {@link done}.
* only access path; buffered output remains readable after exit. Composition
* teardown (the subprocess service's disposal) kills running processes and
* awaits {@link done}; an executor-only reload leaves them running.
*/
export interface BashProcess {
/** Process lifecycle state (settled exactly once). */

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../sandbox/sandbox"
},

View File

@@ -52,6 +52,7 @@
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",

View File

@@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
ctx.llm.registerAdapter(['mock'], adapter)

View File

@@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
@@ -32,8 +33,9 @@ async function setup() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
return ctx
}
@@ -46,8 +48,9 @@ async function setupWithTasks() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
return ctx
}
@@ -275,8 +278,9 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
expect(text(result)).toContain('[output truncated; full output: ')
@@ -383,6 +387,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -400,6 +405,7 @@ describe('bash tool', () => {
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(0)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -411,6 +417,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
ToolBash.apply(ctx, {})
const schema = ctx.tools.schemas()[0]!
@@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ToolBash, { enableRunInBackground: false })

View File

@@ -752,6 +752,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'subprocess',
summary: 'Abstract subprocess service.',
methods: [
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */',
},
],
},
{
key: 'systemPrompt',
summary: 'Registry service for the prompt inputs assembled before each model step.',
@@ -2263,6 +2273,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentStopReasonMap',
declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}',
},
{
name: 'SubprocessCollect',
declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}',
},
{
name: 'SubprocessCollectedOutputs',
declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}',
},
{
name: 'SubprocessHandle',
declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise<SubprocessOutcome>;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
},
{
name: 'SubprocessOutcome',
declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}',
},
{
name: 'SubprocessOutputMode',
declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;',
},
{
name: 'SubprocessOutputRead',
declaration: 'export interface SubprocessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}',
},
{
name: 'SubprocessOutputReader',
declaration: 'export interface SubprocessOutputReader {\n readFrom(fromByte: number): SubprocessOutputRead;\n}',
},
{
name: 'SubprocessSpawnSpec',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record<string, string> | undefined;\n}',
},
{
name: 'SubprocessStdinMode',
declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};',
},
{
name: 'SubprocessStdio',
declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}',
},
{
name: 'SurfaceEvent',
declaration: 'export type SurfaceEvent = SessionEvent<SurfaceEventType> & {\n surfaceOp: SurfaceOp;\n};',

View File

@@ -33,7 +33,7 @@ const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
'acp/acp', 'examples/acp-demo', 'util/paths',
@@ -95,6 +95,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
' name: \'./mock-llm.mjs\'',
'- id: subprocess',
' name: \'@deepseek-ai/dsh-subprocess-local\'',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',

View File

@@ -35,6 +35,8 @@ const CORDIS_YML = `
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: acp-agent

View File

@@ -65,6 +65,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -5,6 +5,7 @@ import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -52,6 +53,7 @@ beforeEach(async () => {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
await ctx.plugin(agentSpine, {

View File

@@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
'session-persistence/session-persistence-jsonl',
'context/workspace-context',
@@ -80,6 +80,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.ts'",
'- id: subprocess',
" name: '@deepseek-ai/dsh-subprocess-local'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',

View File

@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",

View File

@@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
@@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
})

View File

@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
await fiber.dispose()

View File

@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
await mountAgentLoopTestDependencies(ctx)
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath, ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
@@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the session cwd).
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
await fiber.dispose()
@@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))

View File

@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
await mountAgentLoopTestDependencies(ctx)
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
@@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], adapter)

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
README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555
README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877
README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178
README.zh.md: 290601d455ba2012d0c1f5c97503ae87304881cb

View File

@@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
@@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |

View File

@@ -12,7 +12,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
- 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本、所请求操作然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。
- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认
- 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
@@ -23,7 +23,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``SECRET``TOKEN` 的变量不会转发)。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |

View File

@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -42,6 +43,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7",
"typescript": "^6.0.3",

View File

@@ -1,16 +1,17 @@
/**
* A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
* requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
* from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
* commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
* child handle so the instance owns process-signal teardown.
* A JSON-RPC endpoint over one language server spawned through the subprocess
* seam. Owns id correlation, outbound requests/notifications, and inbound
* server→client requests: it answers `workspace/configuration` from static
* config, and rejects `workspace/applyEdit` (this host never applies edits or
* runs commands). It caps stderr, surfaces framing/decoder failures as a
* fatal close, and exposes tree-scoped termination through the handle so the
* instance owns teardown; group/tree mechanics live in the seam's
* implementation.
* @module @deepseek-ai/dsh-lsp-local/connection
*/
import type { ChildProcessByStdio } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { Writable } from 'node:stream'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { encodeMessage, MessageDecoder } from './framing.ts'
/** How to launch the server and answer its config requests. */
@@ -27,6 +28,12 @@ export interface ConnectionSpec {
readonly maxMessageBytes: number
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
/**
* The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of
* {@link LspConnection.terminate}'s escalation, and the bound for draining
* pipes a surviving helper still holds after the server exits.
*/
readonly killGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
@@ -48,178 +55,92 @@ export type ConnectionWriter = (
done: (error?: Error | null) => void,
) => void
/** Host operations used to signal a detached process tree. */
export interface ProcessTreeOperations {
/** Signal a POSIX process group. */
readonly signal: (target: number, signal: NodeJS.Signals) => void
/** Signal the direct child when POSIX group signaling is unavailable. */
readonly killChild: (signal: NodeJS.Signals) => void
/** Terminate a Windows process tree by root pid. */
readonly taskkill: (pid: number) => void
}
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
export interface TaskkillResult {
/** Process exit status, or null when spawning failed. */
readonly status: number | null
/** Spawn failure, when the executable could not run. */
readonly error?: Error
}
/** Invoke a command synchronously for the Windows taskkill adapter. */
export type TaskkillRunner = (
command: string,
args: string[],
options: { stdio: 'ignore' },
) => TaskkillResult
/** Invoke the host process-signal primitive for a POSIX process group. */
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
/** taskkill status for "process not found": the requested process tree is already absent. */
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */
export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
/**
* Terminate one Windows process tree and wait for taskkill to finish.
* @param pid - root process id.
* @param run - command runner; tests inject results without requiring Windows.
*/
export function taskkillProcessTree(
pid: number,
run: TaskkillRunner = spawnSync,
): void {
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
if (result.error !== undefined) throw result.error
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
}
/**
* Signal one POSIX process group through an injectable host primitive.
* @param target - negative process-group id.
* @param signal - requested signal.
* @param run - host signal runner; tests inject it without touching real processes.
*/
export function signalProcessGroup(
target: number,
signal: NodeJS.Signals,
run: ProcessSignalRunner = processSignalRunner,
): void {
run(target, signal)
}
/**
* Wait until a process-tree liveness probe reports exit.
* @param isAlive - process-tree liveness probe.
* @param signal - optional bound for the wait.
* @param yieldNow - event-loop yield primitive.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
export async function waitForTreeExit(
isAlive: () => boolean,
signal?: AbortSignal,
yieldNow: () => Promise<unknown> = yieldToEventLoop,
): Promise<boolean> {
while (isAlive()) {
if (signal?.aborted) return false
await yieldNow()
}
return true
}
/**
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
* child; Windows requires taskkill to reach the full tree.
* @param platform - host platform.
* @param pid - detached root process id.
* @param signal - requested termination signal.
* @param operations - host operations.
*/
export function signalProcessTree(
platform: NodeJS.Platform,
pid: number,
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
operations.signal(-pid, signal)
} catch {
try {
operations.killChild(signal)
} catch {
// The direct child already exited; teardown remains idempotent.
}
}
}
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
private readonly handle: SubprocessHandle
private readonly stdin: Writable
private readonly decoder: MessageDecoder
private readonly pending = new Map<number, Pending>()
private nextId = 1
private stderr = Buffer.alloc(0)
private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise<void>
/**
* @param spec - how to launch the server and answer its config requests.
* @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`).
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
private readonly spec: ConnectionSpec,
spec: ConnectionSpec,
spawner: ConnectionSpawner,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
this.child = spawn(spec.command, [...spec.args], {
// stdin/stdout are piped protocol streams this endpoint frames itself;
// stderr is a collected diagnostic tail (no spill — the bounded tail IS
// the contract). The seam owns detachment and tree-scoped signalling.
this.handle = spawner({
argv: [spec.command, ...spec.args],
cwd: spec.cwd,
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.killGraceMs,
// spec.env mixes the scrubbed base with explicit config entries; the
// seam merges the whole map after its own ambient scrub, so a
// configured DSH_* fact reaches the child.
env: spec.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (this.handle.stdin === undefined || this.handle.stdout === undefined) {
throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream')
}
/* v8 ignore stop */
this.stdin = this.handle.stdin
this.closed = new Promise<void>((resolve) => {
this.child.on('close', () => {
const close = (): void => {
const reason = this.closeReason ?? new Error(this.exitMessage())
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
// (a closed process sends no further responses).
this.closeReason = reason
this.failAll(reason)
resolve()
}
this.handle.done.then(close, (error: unknown) => {
// A spawn-level failure never produces a close event; the rejection is
// the fatal cause and the close boundary at once.
this.fail(asError(error))
close()
})
})
this.child.on('error', (error) => { this.fail(error) })
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
// waiting for a process-close event that may never arrive.
this.child.stdin.on('error', (error) => { this.fail(error) })
this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
this.stdin.on('error', (error) => { this.fail(error) })
this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
}
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
get pid(): number {
/* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
return this.child.pid ?? -1
return this.handle.pid
}
/** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string {
return this.stderr.toString('utf8')
/* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */
return this.handle.collected.stderr?.readFrom(0).text ?? ''
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
@@ -289,14 +210,9 @@ export class LspConnection {
return this.nextId
}
/** Request termination of the server's process tree. */
/** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
terminate(): void {
this.signalTree('SIGTERM')
}
/** Force termination of the server's process tree. */
kill(): void {
this.signalTree('SIGKILL')
this.handle.terminate()
}
/**
@@ -305,39 +221,7 @@ export class LspConnection {
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
}
/** Signal the whole process tree. */
private signalTree(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
signalProcessTree(process.platform, pid, sig, {
signal: signalProcessGroup,
killChild: this.child.kill.bind(this.child),
taskkill: taskkillProcessTree,
})
}
/** Whether the detached tree's root or POSIX process group is still alive. */
private processTreeAlive(): boolean {
const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false
try {
process.kill(-pid, 0)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
/* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes
whether lifecycle tests observe this branch platform-dependent. */
if (code === 'ESRCH') return false
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */
if (code === 'EPERM') return true
return this.child.exitCode === null && this.child.signalCode === null
/* v8 ignore stop */
}
return await this.handle.waitForExit(signal)
}
private onStdout(chunk: Buffer): void {
@@ -346,30 +230,15 @@ export class LspConnection {
messages = this.decoder.push(chunk)
} catch (error) {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
// terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then
// the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window).
this.fail(asError(error))
this.signalTree('SIGKILL')
this.handle.terminate()
return
}
for (const message of messages) this.dispatch(message)
}
private onStderr(chunk: Buffer): void {
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
// before it exits, so the final bounded segment is the useful one.
const cap = this.spec.maxStderrBytes
if (chunk.length >= cap) {
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
return
}
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
this.stderr = Buffer.concat([
this.stderr.subarray(this.stderr.length - retainedBytes),
chunk,
], retainedBytes + chunk.length)
}
private dispatch(message: unknown): void {
if (message === null || typeof message !== 'object') return
const frame = message as Record<string, unknown>
@@ -423,7 +292,7 @@ export class LspConnection {
reject(error)
}
try {
this.writer(this.child.stdin, message, done)
this.writer(this.stdin, message, done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {

View File

@@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import { LspInstance } from './instance.ts'
import type { ConnectionSpawner } from './connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts'
export const name = 'lsp-local'
/** Services required by this plugin. */
export const inject = ['lsp']
export const inject = ['lsp', 'subprocess']
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
@@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void {
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
return new LocalLspProvider(providerId, resolved, childEnv, executable)
return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
})
ctx.effect(() => {
@@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider {
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
private readonly spawner: ConnectionSpawner,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
@@ -285,7 +288,7 @@ class LocalLspProvider implements LspProvider {
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}
return new LspInstance(spec)
return new LspInstance(spec, this.spawner)
}
/** Dispose every live instance and block further queries. */
@@ -302,12 +305,9 @@ class LocalLspProvider implements LspProvider {
}
}
/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
const scrubbed = Object.entries(process.env).filter(
([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
) as [string, string][]
return { ...Object.fromEntries(scrubbed), ...extra }
return { ...scrubbedParentEnv(), ...extra }
}
/**

View File

@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
@@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec {
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
readonly killGraceMs: number
}
/**
* Force-kill a process tree only when graceful termination did not make it exit.
* @param treeExited - whether the tree exited within its grace period.
* @param forceKill - forceful process-tree termination primitive.
*/
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
if (!treeExited) forceKill()
}
/**
@@ -67,10 +56,11 @@ export class LspInstance {
/**
* @param spec - the launch, initialize, and teardown parameters.
* @param spawner - the subprocess seam's spawn function.
* @param writer - optional connection writer used by transport conformance tests.
*/
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
@@ -310,17 +300,14 @@ export class LspInstance {
await abortable(this.connection.closed, signal)
}
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
/**
* Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL),
* then await leader and helper exit. The awaits are unbounded on purpose:
* the seam's escalation already committed to SIGKILL, so quiescence — not
* another timer — is the postcondition disposal owes its callers.
*/
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let treeExited: boolean
try {
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([
this.connection.closed,
this.connection.waitForProcessTreeExit(),

View File

@@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const { Context } = await import('cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
fake: {

View File

@@ -1,18 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import {
signalProcessGroup,
signalProcessTree,
taskkillProcessTree,
waitForTreeExit,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type {
ConnectionWriter,
ProcessSignalRunner,
ProcessTreeOperations,
TaskkillRunner,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -23,7 +14,7 @@ let open: LspConnection[] = []
afterEach(async () => {
for (const conn of open) {
conn.kill()
conn.terminate()
await conn.closed
}
open = []
@@ -39,11 +30,12 @@ function connect(
command: process.execPath,
args: [fixtureServer],
cwd: process.cwd(),
env: { ...process.env as Record<string, string>, ...env },
env: { ...scrubbedParentEnv(), ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
killGraceMs: 3_000,
configuration: { setting: 42 },
}, (method, params) => {
}, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
return onServerRequest(method, params)
})
@@ -59,16 +51,25 @@ describe('LspConnection', () => {
expect(conn.pid).toBeGreaterThan(0)
})
it('forwards explicit DSH_* env entries to the child', async () => {
// A configured DSH_* fact must reach the child: the seam scrubs only the
// ambient namespace, and the explicit entry merges after that scrub. The
// fixture echoes the named variable back as hover text.
const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' })
await conn.request('initialize', { capabilities: {} })
expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' })
})
it('rejects a request when the server replies with an error', async () => {
const conn = connect({ LSP_FAKE_ERROR: '1' })
await conn.request('initialize', { capabilities: {} })
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
})
it('treats signaling an already-closed child as a teardown race', async () => {
it('treats terminating an already-closed child as a teardown race', async () => {
const conn = connectScript('')
await conn.closed
expect(() => { conn.kill() }).not.toThrow()
expect(() => { conn.terminate() }).not.toThrow()
})
it('answers a server workspace/configuration request from static config', async () => {
@@ -148,11 +149,12 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec
command: process.execPath,
args: ['-e', script],
cwd: process.cwd(),
env: { ...process.env as Record<string, string> },
env: scrubbedParentEnv(),
maxMessageBytes: 16_000_000,
maxStderrBytes,
killGraceMs: 3_000,
configuration: null,
}, () => Promise.resolve(null), writer)
}, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
@@ -166,8 +168,9 @@ describe('LspConnection edge behavior', () => {
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
killGraceMs: 3_000,
configuration: null,
}, () => Promise.resolve(null))
}, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)
await expect(conn.request('initialize', {})).rejects.toThrow()
})
@@ -248,72 +251,6 @@ describe('LspConnection edge behavior', () => {
})
})
describe('process-tree signaling', () => {
it('forwards POSIX process-group signals through the host runner', () => {
const run: ProcessSignalRunner = vi.fn(() => true)
signalProcessGroup(-42, 'SIGKILL', run)
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('waits for tree exit and stops when its bound aborts', async () => {
const isAlive = vi.fn()
.mockReturnValueOnce(true)
.mockReturnValue(false)
const yieldNow = vi.fn(() => Promise.resolve())
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
expect(yieldNow).toHaveBeenCalledOnce()
const controller = new AbortController()
controller.abort()
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
})
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
const operations = fakeProcessTreeOperations()
signalProcessTree('win32', 42, 'SIGTERM', operations)
expect(operations.taskkill).toHaveBeenCalledWith(42)
expect(operations.signal).not.toHaveBeenCalled()
signalProcessTree('linux', 42, 'SIGKILL', operations)
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
const fallback = fakeProcessTreeOperations()
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
expect(fallback.killChild).not.toHaveBeenCalled()
})
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
const posixGone = fakeProcessTreeOperations()
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
})
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
taskkillProcessTree(42, success)
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
const spawnFailure = new Error('cannot spawn taskkill')
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
})
})
/** Create observable process-tree operations without touching host processes. */
function fakeProcessTreeOperations(): ProcessTreeOperations {
return {
signal: vi.fn(),
killChild: vi.fn(),
taskkill: vi.fn(),
}
}
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()

View File

@@ -58,7 +58,13 @@ function resultFor(method: string): unknown {
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
case 'textDocument/hover': {
// LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
// contents — a test can assert exactly what env reached this process.
const echoName = process.env.LSP_FAKE_ECHO_ENV
if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
return envJson('LSP_FAKE_HOVER', null)
}
default: return null
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,9 +6,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -38,7 +39,7 @@ function makeInstance(
command: process.execPath,
args: [fixtureServer],
cwd: ws,
env: { ...process.env as Record<string, string>, ...env },
env: { ...scrubbedParentEnv(), ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
@@ -46,7 +47,7 @@ function makeInstance(
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
}, writer)
}, spawnSubprocess, writer)
live.push(instance)
return instance
}
@@ -67,7 +68,7 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
command: process.execPath,
args: ['-e', script],
cwd: ws,
env: { ...process.env as Record<string, string> },
env: scrubbedParentEnv(),
configuration: null,
initializationOptions: null,
maxMessageBytes: 16_000_000,
@@ -75,7 +76,7 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
})
}, spawnSubprocess)
live.push(instance)
return instance
}
@@ -254,14 +255,6 @@ describe('LspInstance query and abort', () => {
})
describe('LspInstance disposal', () => {
it('escalates only when the process tree survives its grace period', () => {
const forceKill = vi.fn()
escalateProcessTree(false, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
escalateProcessTree(true, forceKill)
expect(forceKill).toHaveBeenCalledOnce()
})
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({

View File

@@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -45,6 +46,7 @@ async function mount(
): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
@@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => {
await writeFile(join(ws, 'a.py'), 'x = 1\n')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
@@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => {
it('rejects at load when the command is not found', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {

View File

@@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
@@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => {
it('skips empty PATH segments and fails when the command is absent', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
@@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => {
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
@@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
@@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
@@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => {
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
@@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => {
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
@@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
@@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server table at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
@@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server id at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
@@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => {
it('resolves every executable before publishing any provider', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => {
it('rolls back earlier registrations when a later server conflicts', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },

View File

@@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -52,6 +53,7 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: {

View File

@@ -29,6 +29,9 @@
{
"path": "../lsp"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}

View File

@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
@@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
inline: {

View File

@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -40,6 +41,7 @@
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@modelcontextprotocol/server-everything": "^2026.7.4",
"@modelcontextprotocol/server-filesystem": "^2026.7.4",

View File

@@ -9,22 +9,17 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { Config } from './index.ts'
/**
* Credential-shaped ambient env vars are NOT forwarded to the child by default
* (the parent harness's own secrets must not leak into a spawned process
* implicitly). Same pattern as `dsh-subagent-acp`.
* The subprocess seam's scrubbed parent env (credential-shaped and stale
* `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
* actual spawn, so this transport shares the scrub definition rather than the
* spawn path.
*/
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...extra }
return { ...scrubbedParentEnv(), ...extra }
}
/**

View File

@@ -21,6 +21,9 @@
{
"path": "../../core/tools"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -26,7 +27,6 @@ export const name = 'pty-local'
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
interface SandboxModeFenceState {
pty: Context['pty']
sandboxPolicy: Context['sandboxPolicy']
@@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
}
// node-pty owns the spawn; the base env shares the subprocess seam's scrub.
return {
...env,
...scrubbedParentEnv(),
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',

View File

@@ -93,13 +93,6 @@ class LocalSendOperation implements PtySendOperation {
return this.promise.promise
}
/**
* Accumulate sanitized output into the operation's viewport. Output that arrives after
* the operation settles is dropped here and survives only in the session scrollback, so
* a caller waiting on this operation for a marker cannot observe one the child prints
* after any readiness tier ended the send.
* @param text Sanitized text to append while the operation is still active.
*/
append(text: string): void {
if (!this.finished) this.output.append(text)
}

View File

@@ -68,7 +68,9 @@ async function harness(
}
// PtySendOperation.append drops output once the operation settles, so this only
// observes a marker the child prints while the send is still active.
// observes a marker the child prints while `operation` is still active. A caller
// whose child is slow to print must raise the harness `timing` bounds too;
// extending this deadline alone cannot recover output the operation never collected.
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let output = ''

View File

@@ -32,6 +32,9 @@
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}

View File

@@ -35,6 +35,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -44,6 +45,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
summary: 'Command execution',
mode: 'exclusive',
required: true,
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }],
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
],
options: [
{
id: 'local',

View File

@@ -5,6 +5,7 @@
*/
import { execFile, spawn } from 'node:child_process'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { promisify } from 'node:util'
import type { PackageJsonFile } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
@@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
}
}
/** Remove credential-shaped environment variables from spawned commands. */
export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
/**
* Remove credential-shaped environment variables from spawned commands.
* @param environment - source environment (injectable for tests); the default
* path shares the subprocess seam's scrub so every harness spawner drops the
* same names.
*/
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (environment === undefined) return scrubbedParentEnv()
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
}

View File

@@ -33,6 +33,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}

View File

@@ -191,7 +191,7 @@ function resolveRoute(
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
return [
'Create a concise title for an AI coding-assistant session from the supplied human messages.',
'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
'Use the language of the messages.',
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
].join('\n')

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
README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149
README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354
README.md: 8414836efd756f60258566ae3e4e00de2d4110d7
README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188

View File

@@ -10,10 +10,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures.
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md).

View File

@@ -10,10 +10,9 @@ subagent seam 允许 agent智能体把工作委派给子 agent。与 [bash
| `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect | 无 |
| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents` |
| `subagent-subprocess/` | 共享进程外机制环境变量清理、dispose资源释放阶梯、隔离配置目录纯库不注册任何内容 | 无 |
| `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACPAgent Client Protocol驱动的子 agent | (注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture测试前置数据替换子 agent 边界。
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯)。测试只用包内 fixture测试前置数据替换子 agent 边界。
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: d1ba03cf5256ad4889c4893bfe11af42bd627f9d
README.zh.md: 5763ee9a22c1d0bfe12c7da2b7d996911b55cc49
README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0
README.zh.md: 41723398ffa34995282814011cd03b2fc1cc5a4b

View File

@@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented.
## Capabilities and context
@@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
## Process boundary
The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).

View File

@@ -14,7 +14,7 @@ ACPAgent Client Protocol提供方会在全新的子进程中运行每个 s
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM等待 `disposeGraceMs` 后再使用 SIGKILLWindows 直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。
## 能力与上下文
@@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
## 进程边界
子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env`。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
子进程由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn共享的凭据清除先移除名称形似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值stderr 以 inherit 方式直通父进程自身的流dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。

View File

@@ -32,7 +32,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,7 +47,8 @@
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
export const inject = ['subagents']
export const inject = ['subagents', 'subprocess']
/** Config: how to spawn and drive the child ACP agent process. */
export interface Config {
@@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider {
env: this.config.env,
disposeEofGraceMs: this.config.disposeEofGraceMs,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spec => this.ctx.subprocess.spawn(spec),
onError: (error, stopReason) => {
// The seam forbids `result` rejecting, so a child-level failure is
// flattened to a stop reason — preserve it here rather than losing it.

View File

@@ -8,9 +8,8 @@
* @module @deepseek-ai/dsh-subagent-acp/run
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { Readable, Writable } from 'node:stream'
import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream'
import {
ClientSideConnection,
ndJsonStream,
@@ -26,7 +25,7 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
@@ -47,9 +46,12 @@ export interface AcpRunSpec {
permission: PermissionPolicy
/**
* Extra environment variables to ADD for the child (e.g. the child harness's
* `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see
* {@link buildChildEnv}. A value here is forwarded even if its name matches
* the credential-scrub pattern (an explicit opt-in for the child's own creds).
* `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed
* parent env. A value here is forwarded even if its name matches the
* credential-scrub pattern (an explicit opt-in for the child's own creds).
* Explicit `DSH_*` entries are deployment-owned facts for the child harness
* (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that
* dropped their stale ambient namesakes.
*/
env: Record<string, string>
/**
@@ -65,6 +67,12 @@ export interface AcpRunSpec {
* fills this from its `disposeGraceMs` config.
*/
disposeGraceMs: number
/**
* Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the
* child rides the shared scrub, tree-scoped teardown, and service-owned
* lifetime instead of a package-local child_process path.
*/
spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/**
* Sink for a child-level failure that the run flattened into a stop reason
* (the seam contract forbids `result` rejecting). The driver calls this with
@@ -82,6 +90,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, ms)
try {
return await child.waitForExit(controller.signal)
} finally {
clearTimeout(timer)
}
}
/**
* Cooperative teardown ladder for an out-of-process agent, over the seam's
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
* window to flush persistence and reap its own descendants), then the
* terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
* confirmation wait.
* @param child - the spawned ACP child's handle.
* @param eofGraceMs - tier-1 window after stdin EOF.
* @param graceMs - confirmation window after the escalation's SIGKILL.
* @throws when the tree still has not exited `graceMs` after forced termination.
*/
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
child.stdin?.end()
if (await treeExitsWithin(child, eofGraceMs)) return
// terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
// (this plugin passes disposeGraceMs there), so the bound covers both the
// escalation window and an equal confirmation window after the SIGKILL.
child.terminate()
if (!(await treeExitsWithin(child, graceMs * 2))) {
throw new Error('ACP child process tree did not exit within its dispose windows')
}
}
/**
* Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
* @param reason - the terminal reason from the child's `session/prompt` response.
@@ -159,21 +207,35 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// each other or with a local agent that happens to use the same session id.
const id = SessionId(randomUUID())
// Keep diagnostics on parent stderr; only ACP output contributes to the result.
const child = spawn(spec.command, spec.args, {
// Keep diagnostics on parent stderr ('inherit'); only ACP output contributes
// to the result. The seam's scrub drops ambient credentials and DSH_* names
// while spec.env (the child's own key, its deployment facts) merges after it.
const child = spec.spawn({
argv: [spec.command, ...spec.args],
cwd: spec.cwd,
env: buildChildEnv(spec.env),
stdio: ['pipe', 'pipe', 'inherit'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
// Capture the child-process error event immediately.
const spawnFailed = spawnFailure(child)
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (child.stdin === undefined || child.stdout === undefined) {
throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream')
}
/* v8 ignore stop */
// Spawn-level failure surfaces as `done` rejecting into the startup race; a
// clean exit must never win it, so the success arm parks forever. (The ACP
// connection observing its streams closing bounds a child that exits
// without speaking the protocol.)
const spawnFailed: Promise<never> = child.done.then(
/* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
() => new Promise<never>(() => {}),
(err: unknown) => Promise.reject(toError(err)),
)
spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ })
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
}))
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
@@ -207,8 +269,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
const conn = new ClientSideConnection(
makeClient,
ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
NodeWritable.toWeb(child.stdin) as WritableStream<Uint8Array>,
NodeReadable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
),
)
@@ -252,7 +314,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
sessionId = returnedSessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
spawnFailed,
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
} catch (error: unknown) {

View File

@@ -4,6 +4,9 @@
* fully scripted by environment variables — no model, no network:
*
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
* - `MOCK_ECHO_ENV` — if set to a variable NAME, stream that variable's value
* (or `<NAME unset>`) instead of MOCK_TEXT — asserts what
* environment actually reached the child process.
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
* (`end_turn` default, or `max_tokens`/`refusal`/…).
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
@@ -67,7 +70,12 @@ import {
type StopReason,
} from '@agentclientprotocol/sdk'
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
// When MOCK_ECHO_ENV names a variable, stream that variable's value in place
// of MOCK_TEXT — lets a test assert exactly what env reached this process.
const echoEnvName = process.env.MOCK_ECHO_ENV
const TEXT = echoEnvName !== undefined
? process.env[echoEnvName] ?? `<${echoEnvName} unset>`
: process.env.MOCK_TEXT ?? 'mock child answer'
const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
const HANG = process.env.MOCK_HANG === '1'

View File

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import * as acp from '../src/index.ts'
@@ -21,7 +22,7 @@ const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cord
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE).
// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is
// The subprocess seam scrubs ambient creds while spec.env merges after it, so the model key is
// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only.
const childLaunch = resolveExampleLaunch({
srcBin: binScript,
@@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: childLaunch.command,
@@ -81,6 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: childLaunch.command,

View File

@@ -6,10 +6,11 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
/**
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
@@ -41,6 +42,7 @@ interface SetupEnv {
async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -98,21 +100,104 @@ describe('acpContentText / toAcpPrompt', () => {
})
})
describe('buildChildEnv', () => {
it('drops credential-shaped ambient vars but keeps the explicit extras', () => {
process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me'
describe('child env layering (through the subprocess seam)', () => {
it('drops credential-shaped ambient vars but keeps the explicit extras', async () => {
process.env.ACP_TEST_AMBIENT_SECRET_TOKEN = 'leak-me'
try {
const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' })
// The credential-shaped ambient var is scrubbed.
expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined()
// The explicitly-supplied key survives (an opt-in for the child's creds).
expect(env.DEEPSEEK_API_KEY).toBe('explicit')
// A normal ambient var is forwarded.
expect(env.PATH).toBe(process.env.PATH)
// The spec.env layer merges after the seam's scrub, so the child's own
// explicitly-forwarded key survives while ambient credentials do not.
const running = spawnSubprocess({
argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'],
cwd: process.cwd(),
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 1000,
env: { DEEPSEEK_API_KEY: 'explicit' },
})
await running.done
expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]')
} finally {
delete process.env.DSH_ACP_TEST_SECRET_TOKEN
delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN
}
})
it('forwards explicit DSH_* config entries to the child', async () => {
// A deployment sets child-harness facts like DSH_PERMISSION_MODE in
// config.env; the seam's scrub drops only the AMBIENT namesakes, so the
// explicit entry merges after it and the child must see the value.
const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' })
const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
const result = await run.result
await run.dispose()
const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('')
expect(text).toBe('managed')
await ctx.fiber.dispose()
})
})
describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => {
const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
argv: ['bash', '-c', command],
cwd: process.cwd(),
stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const child = bash('read -r line; exit 0')
await disposeAcpChild(child, 5_000, 200)
const outcome = await child.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
})
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
const child = bash('sleep 60')
await disposeAcpChild(child, 100, 5_000)
const outcome = await child.done
expect(outcome.signal).toBe('SIGTERM')
})
it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => {
const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore')
// Wait for the trap to arm so SIGTERM cannot race the default handler.
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await disposeAcpChild(child, 50, 2_000)
const outcome = await child.done
expect(outcome.signal).toBe('SIGKILL')
})
it('throws when the tree survives even the escalation window', async () => {
// A handle whose tree never exits (waitForExit only ever aborts): the
// ladder must fail loud instead of resolving over survivors. Built as a
// stub because the ladder composes only public verbs.
const never: Parameters<typeof disposeAcpChild>[0] = {
pid: 1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: {},
done: new Promise(() => {}),
terminate: () => {},
waitForExit: (signal?: AbortSignal) => new Promise((resolve) => {
signal?.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
}
await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/)
})
it('observes a spawn-level rejection and returns without a process to reap', async () => {
const child = spawnSubprocess({
argv: ['bash', '-c', 'true'],
cwd: '/nonexistent-dir-dsh-acp-ladder-test',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined()
await expect(child.done).rejects.toThrow()
})
})
describe('cwd resolution', () => {
@@ -140,6 +225,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
// A command that would create the sentinel if the child were ever spawned.
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
@@ -158,6 +244,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -185,6 +272,7 @@ describe('cwd resolution', () => {
const absolute = resolve(relative)
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -204,6 +292,7 @@ describe('cwd resolution', () => {
// reintroduce the launch-directory fallback this resolution removed.
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -224,6 +313,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -242,6 +332,7 @@ describe('cwd resolution', () => {
it('rejects a config cwd that is not an accessible directory at load', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -283,6 +374,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
@@ -360,7 +452,7 @@ describe('dsh-subagent-acp', () => {
await expect(startAcpRun(
request('p', controller.signal),
// `touch <sentinel>` — runs only if the process is actually spawned.
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
)).rejects.toThrow('aborted before the ACP child started')
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
@@ -385,6 +477,7 @@ describe('dsh-subagent-acp', () => {
},
disposeEofGraceMs: 1000,
disposeGraceMs: 100,
spawn: spawnSubprocess,
})).rejects.toThrow('ACP child published without a session id')
// Startup rejects only after its private child reaches quiescence. The
// marker proves rollback closed stdin and allowed the child's EOF flush.
@@ -412,6 +505,7 @@ describe('dsh-subagent-acp', () => {
// small so the whole ladder finishes well within the 4000ms bound.
disposeEofGraceMs: 150,
disposeGraceMs: 150,
spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
@@ -459,6 +553,7 @@ describe('dsh-subagent-acp', () => {
},
disposeEofGraceMs: 2000,
disposeGraceMs: 50,
spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
// Wait until the child is fully booted with its prompt in flight (its ACP
@@ -492,6 +587,7 @@ describe('dsh-subagent-acp', () => {
// Tiny EOF grace so the ignored-EOF window elapses quickly.
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
await waitForFile(ready)
@@ -587,7 +683,7 @@ describe('dsh-subagent-acp', () => {
it('rejects a spawn failure after provider-owned cleanup', async () => {
await expect(startAcpRun(
request(),
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
)).rejects.toThrow()
})
@@ -601,6 +697,7 @@ describe('dsh-subagent-acp', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -626,6 +723,7 @@ describe('dsh-subagent-acp', () => {
for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
.rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
await ctx.fiber.dispose()
@@ -635,6 +733,7 @@ describe('dsh-subagent-acp', () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: '/nonexistent/acp-agent-binary',
@@ -661,6 +760,7 @@ describe('dsh-subagent-acp', () => {
env: { MOCK_CRASH_ON_PROMPT: '1' },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
spawn: spawnSubprocess,
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
},
)
@@ -699,6 +799,7 @@ describe('dsh-subagent-acp', () => {
env: { MOCK_CRASH_ON_PROMPT: '1' },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
spawn: spawnSubprocess,
onError: () => { throw new Error('sink boom') },
},
)
@@ -763,6 +864,7 @@ describe('dsh-subagent-acp', () => {
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
expect(ctx.subagents.list()).toEqual(['acp'])
await fiber.dispose()
@@ -772,7 +874,7 @@ describe('dsh-subagent-acp', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in acp).toBe(false)
expect(acp.name).toBe('subagent-acp')
expect(acp.inject).toEqual(['subagents'])
expect(acp.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(acp) as Record<string, unknown>
expect(unwrapped).toBe(acp)

View File

@@ -27,7 +27,7 @@
"path": "../subagent"
},
{
"path": "../subagent-subprocess"
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/loader-smoke"

View File

@@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess'
/** Cordis companion plugin name. */
export const name = 'subagent-inprocess-invariant'
export const name = 'subagent-insubprocess-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService)

View File

@@ -1,55 +0,0 @@
# @deepseek-ai/dsh-subagent-subprocess
English | [中文](README.zh.md)
Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md).
Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library.
## What it exports
### `buildChildEnv(extra)`
The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child.
### `spawnFailure(child)`
Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles.
### `disposeChildProcess(child, graces)`
The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact;
2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`;
3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal.
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush.
The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
### `createIsolatedConfigDir(prefix, pinnedPath?)`
A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose.
- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent.
- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle.
## Testing
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end.
## Model Experience
Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment.
- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder.
- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal.
- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state.

View File

@@ -1,55 +0,0 @@
# @deepseek-ai/dsh-subagent-subprocess
[English](README.md) | 中文
用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent智能体作为子进程派生例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。
每个可调项都是**参数**dispose资源释放阶梯每次调用时接收宽限时间配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。
## 导出内容
### `buildChildEnv(extra)`
凭据环境变量清理采用与 [bash 执行器](../../bash/bash-local/README.md)相同的模式:子进程环境等于环境继承值移除名称形似凭据的变量(`/KEY|SECRET|TOKEN/i`)后,再把 `extra` 叠加到清理结果之后。`PATH``HOME``TMPDIR`、locale 和代理变量会保留,使子 CLI 正常运行;父级自身的秘密绝不会隐式泄漏,而显式提供的凭据(后端 `env` 配置中子进程自己的密钥)仍会传给子进程。
### `spawnFailure(child)`
派生失败捕获:返回一个 promise它会以子进程的第一个 `error` 事件兑现(绝不拒绝)。`ENOENT` 等派生失败是事件而非抛出的异常;没有监听器时 Node 会使父进程崩溃。因此,请在调用 `spawn()` 的同一个 tick 内调用此函数,并在运行结果路径中将其纳入竞速;错误命令随后会作为普通的子进程级失败结算。对于正常派生的子进程,该 promise 永不结算。
### `disposeChildProcess(child, graces)`
平台感知的 dispose 阶梯只会在子进程确实退出后兑现:达到完全停稳,而不只是发出请求(见[防御性模式](../../../docs/defensive-patterns.md)
1. stdin EOF如果 stdin 已建立管道),然后等待 `graces.disposeEofGraceMs`:可协作的子进程自行完全停稳,同时保留其 flush 与嵌套子进程清理;
2. 在 POSIX 上发送 `SIGTERM`,然后等待 `graces.disposeGraceMs`
3. 强制终止POSIX 使用 `SIGKILL`Windows 使用 Node 映射的 `TerminateProcess`;然后最多等待 `graces.disposeGraceMs` 以确认退出。信号错误或未退出会导致 dispose 拒绝。
两个宽限时间(`DisposeLadderGraces`)来自消费插件的 `disposeEofGraceMs`/`disposeGraceMs` Config 字段。POSIX 在优雅信号和强制信号之后都使用 `disposeGraceMs`Windows 跳过冗余的优雅信号但用该值限定强制退出确认时间。EOF 窗口有意独立设置且通常更宽,因为协作式清理可能要等待捕获信号的孙进程和最后一次 flush。
退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。
### `createIsolatedConfigDir(prefix, pinnedPath?)`
为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。
- **全新(默认)**OS 临时根目录下的私有0700`mkdtemp` 目录;`remove()` 会尽力删除它,且绝不拒绝(留下临时目录胜过 dispose 失败),并且是幂等的。
- **固定**(设置 `pinnedPath`):原样返回该路径,绝不创建、绝不移除。通过固定目录在运行间共享子进程状态的部署负责该目录的生命周期。
## 测试
`tests/subagent-subprocess.spec.ts`环境变量清理和配置目录辅助函数使用真实进程环境与真实文件系统运行rm 失败路径在 fs 边界注入拒绝,因为真实递归 rm 失败无法跨平台稳定触发,而且 root 会忽略权限位);退出等待和平台终止路径使用可脚本化的假子进程。[ACP 后端测试套件](../subagent-acp/README.md)会针对真实子进程端到端执行这些机制。
## 模型体验
通过基于进程的 subagent 后端间接产生影响;这些后端的子进程组合受凭据清理和隔离配置目录约束。
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与延期工作
- **凭据清理基于名称**:只移除匹配 `KEY` / `SECRET` / `TOKEN` 的变量;除非后端提供更严格的环境,否则 `PASSWORD` 等名称不同的秘密仍会传入。
- **信号只针对直接子进程**:清理依赖可协作的 CLI 在退出前回收其后代;重新托管或独立脱离的孙进程可能比该阶梯存活更久。
- **全新配置目录的清理是尽力而为**`rm` 失败时会在 OS 临时根目录下留下私有状态,而不会使 dispose 失败。
- **固定配置目录完全由操作方负责**:辅助函数既不创建、校验、锁定,也不移除这些目录,因此并发运行可能共享该状态并发生竞态。

View File

@@ -1,223 +0,0 @@
/**
* Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external
* agent as a child process and must keep the parent deployment's credentials out of it, tear
* it down to quiescence, and isolate it from the host user's on-disk CLI state. This package
* registers no provider; consuming plugins own and validate every timing or path default.
* @module @deepseek-ai/dsh-subagent-subprocess
*/
import type { ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Credential-shaped ambient env vars are NOT forwarded to a child by default
* (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
* spawned process implicitly). Same pattern as the bash executor. The child
* agent needs its OWN credentials to reach a model — those are supplied
* explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER
* the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
* `AWS_SECRET_ACCESS_KEY` does not.
*/
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* The ambient env minus credential-shaped vars, plus the caller's explicit
* env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so
* a child CLI runs normally; only credential-shaped names are dropped.
* @param extra - explicit vars layered on top AFTER the scrub, so a
* credential-shaped name supplied deliberately still reaches the child.
* @returns the environment to spawn the child with.
*/
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...extra }
}
/**
* Capture the child's spawn-level `error` event as a promise. Call in the same tick as
* `spawn()`; otherwise an early event can be unhandled and crash the parent.
* @param child - the just-spawned child process.
* @returns a promise that RESOLVES (never rejects) with the child's first
* `error` event; for a child that spawns cleanly it never settles.
*/
export function spawnFailure(child: ChildProcess): Promise<Error> {
return new Promise<Error>((resolve) => {
child.once('error', (err) => { resolve(err) })
})
}
/**
* Race the child's exit against a timer. Neither outcome leaves anything
* behind on the child: the exit listener is removed on timeout and the timer
* is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll
* loop) never accumulate listeners.
* @param child - the child process to watch.
* @param ms - the wait window in milliseconds.
* @returns `true` if the child exits within `ms` (immediately if it is
* already gone), `false` on timeout.
*/
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const onExit = (): void => {
clearTimeout(timer)
resolve(true)
}
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
const timer = setTimeout(() => {
child.removeListener('exit', onExit)
resolve(false)
}, ms).unref()
child.once('exit', onExit)
})
}
/**
* The two grace periods of the dispose ladder, supplied per call by the
* consuming backend — each plugin carries them as defaulted, validated
* `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is
* deployment-tunable and this library hardcodes nothing.
*/
export interface DisposeLadderGraces {
/**
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
* ON ITS OWN — flush durable state, tear down its own nested subprocesses —
* before the parent escalates to platform termination. A separate (usually WIDER)
* grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
* child's EOF-driven teardown may itself be waiting on a signal-trapping
* grandchild plus a final flush, needing more than one signal-grace of
* headroom.
*/
disposeEofGraceMs: number
/**
* Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
* `SIGKILL`; Windows applies it after the direct forced termination.
*/
disposeGraceMs: number
}
/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
let accepted = false
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.off('exit', onExit)
child.off('error', onError)
}
const settle = (complete: () => void): void => {
if (settled) return
settled = true
cleanup()
complete()
}
const onExit = (): void => { settle(resolve) }
const onError = (error: Error): void => { settle(() => { reject(error) }) }
child.once('exit', onExit)
child.once('error', onError)
const timer = setTimeout(() => {
const disposition = accepted ? 'accepted' : 'refused'
settle(() => {
reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
})
}, ms).unref()
try {
accepted = child.kill('SIGKILL')
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
} catch (error: unknown) {
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
}
})
}
/**
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
* cooperative flush, then use the host's graceful and forced termination semantics. POSIX
* sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node
* maps both signals to `TerminateProcess`.
*
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
* @param platform - the host platform, injectable for unit coverage.
* @throws When forced termination errors or the child does not report exit within
* `disposeGraceMs`.
*/
export async function disposeChildProcess(
child: ChildProcess,
graces: DisposeLadderGraces,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
// Already gone: nothing to reap.
if (child.exitCode !== null || child.signalCode !== null) return
// 1. Close stdin and allow cooperative teardown and durable-state flush.
child.stdin?.end()
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
if (platform !== 'win32') {
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
}
// 3. Force-kill and await a bounded exit edge.
await forceTerminateWithin(child, graces.disposeGraceMs)
}
/**
* A per-run config directory handle for an external CLI child — the target of
* `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to
* the child's environment; call {@link remove} on dispose.
*/
export interface IsolatedConfigDir {
/** The directory to point the child at. */
path: string
/**
* Best-effort cleanup: removes the directory (recursively) iff this handle
* CREATED it — a pinned directory is never removed. Idempotent; never
* rejects (a leftover dir under the OS temp root is preferable to a failed
* dispose).
*/
remove(): Promise<void>
}
/**
* An isolated config dir for one child run, independent of host CLI state. Without
* `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory
* is returned unchanged and remains deployment-owned.
*
* @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
* `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
* @param pinnedPath - a deployment-pinned directory to use instead of a
* fresh one.
* @returns the directory handle: `path` for the child env, `remove()` for
* dispose.
*/
export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise<IsolatedConfigDir> {
if (pinnedPath !== undefined) {
return {
path: pinnedPath,
remove(): Promise<void> {
// A pinned dir is deployment-owned state (config the user asked to
// persist across runs); removing it here would destroy it. No-op.
return Promise.resolve()
},
}
}
const path = await mkdtemp(join(tmpdir(), prefix))
return {
path,
async remove(): Promise<void> {
try {
await rm(path, { recursive: true, force: true })
} catch {
// Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead
// child left an unreadable entry behind).
}
},
}
}

View File

@@ -1,389 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcess } from 'node:child_process'
import {
buildChildEnv,
createIsolatedConfigDir,
disposeChildProcess,
spawnFailure,
} from '../src/index.ts'
// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, rm: vi.fn(actual.rm) }
})
/**
* Unit tests for the shared out-of-process machinery. The env scrub and the
* isolated-config-dir helpers run against the REAL process env and REAL
* filesystem (one exception: the rm-failure path injects its rejection at the
* mocked fs boundary, see above); the exit waits and the dispose ladder run
* against a scriptable fake child so each escalation tier's timing is driven
* deterministically (the ACP backend's suite exercises the same ladder
* against real subprocesses end to end).
*/
/** What fells a scripted {@link FakeChild}. */
type LethalTrigger = 'eof' | NodeJS.Signals
/** Per-scenario script for a {@link FakeChild}. */
interface FakeChildScript {
/**
* The one trigger that makes the child exit (SIGKILL always does,
* uncatchable, like a real process). Omitted: only SIGKILL fells it.
*/
diesOn?: LethalTrigger
/** Delay (ms) between the lethal trigger and the exit event. */
delayMs?: number
/** Complete the scripted exit inside the triggering call. */
synchronousExit?: boolean
/** `false` models a child spawned without a stdin pipe. */
stdin?: boolean
}
/**
* A scriptable stand-in for a ChildProcess carrying exactly the surface the
* helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
* `exit` event.
*/
class FakeChild extends EventEmitter {
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
readonly kills: NodeJS.Signals[] = []
stdinEnded = false
readonly stdin: { end: () => void } | null
constructor(private readonly script: FakeChildScript = {}) {
super()
this.stdin = script.stdin === false
? null
: { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
}
kill(signal: NodeJS.Signals): boolean {
this.kills.push(signal)
this.maybeDie(signal)
return true
}
private maybeDie(trigger: LethalTrigger): void {
// SIGKILL is uncatchable — it always fells the child; any other trigger
// only when the scenario scripts it as the lethal one.
if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
const exit = (): void => {
if (trigger === 'eof') this.exitCode = 0
else this.signalCode = trigger
this.emit('exit', this.exitCode, this.signalCode)
}
if (this.script.synchronousExit === true) exit()
else setTimeout(exit, this.script.delayMs ?? 0)
}
}
/** The helpers take a real ChildProcess; the fake carries the read surface. */
function asChild(fake: FakeChild): ChildProcess {
return fake as unknown as ChildProcess
}
describe('buildChildEnv', () => {
it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
process.env.DSH_PROC_TEST_API_KEY = 'leak'
process.env.dsh_proc_test_secret = 'leak'
process.env.DSH_PROC_TEST_TOKEN = 'leak'
try {
const env = buildChildEnv({})
expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
expect(env.dsh_proc_test_secret).toBeUndefined()
expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
} finally {
delete process.env.DSH_PROC_TEST_API_KEY
delete process.env.dsh_proc_test_secret
delete process.env.DSH_PROC_TEST_TOKEN
}
})
it('forwards normal ambient vars', () => {
expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
})
it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
try {
const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
// The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
} finally {
delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
}
})
it('an extra overrides the ambient value of a non-credential var', () => {
process.env.DSH_PROC_TEST_PLAIN = 'ambient'
try {
expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
} finally {
delete process.env.DSH_PROC_TEST_PLAIN
}
})
})
describe('spawnFailure', () => {
it('resolves (never rejects) with the first error event', async () => {
const fake = new FakeChild()
const failure = spawnFailure(asChild(fake))
const err = new Error('spawn ENOENT')
fake.emit('error', err)
await expect(failure).resolves.toBe(err)
})
it('never settles for a child that spawns cleanly and exits', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM' })
const failure = spawnFailure(asChild(fake))
fake.kill('SIGTERM')
await new Promise<void>(resolve => fake.once('exit', () => { resolve() }))
// A clean lifecycle emits `exit`, never `error` — the capture stays
// pending forever, so a race against it is decided by the other arms.
const settled = await Promise.race([
failure.then(() => 'settled'),
new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
])
expect(settled).toBe('pending')
})
})
describe('disposeChildProcess', () => {
it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
const fake = new FakeChild()
fake.exitCode = 0
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('returns immediately for a child already dead by signal', async () => {
const fake = new FakeChild()
fake.signalCode = 'SIGKILL'
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual([])
expect(fake.exitCode).toBe(0)
})
it('recognizes a child that exits synchronously on stdin EOF', async () => {
const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.exitCode).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('recognizes a child that exits synchronously on SIGTERM', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
// Quiescence, not a request: at resolution the child has ACTUALLY exited
// (the exit event landed, despite the scripted post-SIGKILL delay).
expect(fake.signalCode).toBe('SIGKILL')
})
it('recognizes a child already gone when the final exit wait begins', async () => {
const fake = new FakeChild({ synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
queueMicrotask(() => {
if (marker === 'exitCode') fake.exitCode = 0
else fake.signalCode = 'SIGTERM'
})
return true
})
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('walks the ladder for a child spawned without a stdin pipe', async () => {
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it('propagates a forced-termination error without waiting for the grace', async () => {
const fake = new FakeChild()
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
fake.emit('error', failure)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toBe(failure)
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
const fake = new FakeChild()
const failure = new Error('invalid signal state')
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds a refused forced termination that produces no error or exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds an accepted forced termination that never reports exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return true
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
})
describe('createIsolatedConfigDir', () => {
it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
try {
expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
const st = await stat(dir.path)
expect(st.isDirectory()).toBe(true)
// Windows reports synthetic POSIX mode bits; privacy comes from the
// inherited directory ACL rather than chmod-compatible mode bits.
if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
} finally {
await dir.remove()
}
})
it('creates a distinct dir per call (per-run isolation)', async () => {
const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
try {
expect(a.path).not.toBe(b.path)
} finally {
await a.remove()
await b.remove()
}
})
it('remove() deletes a fresh dir recursively and is idempotent', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
await writeFile(join(dir.path, 'settings.json'), '{}')
await dir.remove()
expect(existsSync(dir.path)).toBe(false)
// Second remove: nothing left to delete, still resolves.
await expect(dir.remove()).resolves.toBeUndefined()
})
it('returns a pinned dir verbatim and NEVER removes it', async () => {
const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
try {
const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
expect(dir.path).toBe(pinned)
await dir.remove()
// The deployment owns a pinned dir's lifecycle — remove() must not touch it.
expect(existsSync(pinned)).toBe(true)
} finally {
await rm(pinned, { recursive: true, force: true })
}
})
it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
expect(dir.path).toBe(missing)
expect(existsSync(missing)).toBe(false)
await dir.remove()
expect(existsSync(missing)).toBe(false)
})
it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
try {
// The swallow contract is error-kind agnostic; EACCES stands in for the
// family (EBUSY, a vanished mount, …) that best-effort must absorb.
vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
await expect(dir.remove()).resolves.toBeUndefined()
// The injected rejection consumed the only rm call — nothing was deleted.
expect(existsSync(dir.path)).toBe(true)
} finally {
await rm(dir.path, { recursive: true, force: true })
}
})
})

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
README.md: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb
README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740
README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b
README.zh.md: e30b6c7f51ed0dffba15a6d1dff632e4ff4c6402

View File

@@ -0,0 +1,12 @@
# subprocess/ — subprocess capability family
English | [中文](README.zh.md)
The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.

View File

@@ -0,0 +1,12 @@
# subprocess/:进程管理能力家族
[English](README.md) | 中文
spawn 受管子进程树的共用归属位置:完全显式的 spawn spec其 stdio 处置方式disposition为 Node 形状、按流划分原始管道、inherit、附带 spill 文件的有界尾部保留收集harness 中所有 spawn 调用方共用的那一份凭据清除;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose资源释放阶梯。命令默认值补全、shell 语义、deadline、协议分帧与呈现留在消费方[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACPAgent Client Protocolsubagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
| 包package | ctx 键 | 角色 |
|---|---|---|
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec``SubprocessHandle`流、基于偏移量的读取器、terminate/waitForExit/dispose以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 |
| [`subprocess-local`](subprocess-local/README.md)`@deepseek-ai/dsh-subprocess-local` | 无 | 本地实现detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose |
服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。

View File

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

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-subprocess-local
English | [中文](README.zh.md)
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)).
## Behavior (and where it came from)
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience
Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-subprocess-local
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程树 spawn依照 spec 中按流划分的 stdio 处置方式disposition完成接线原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围、按 SIGTERM→SIGKILL 升级发送信号。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在各调用方 seam 的配置里([`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。
## 行为(以及设计来源)
- **带平台正确信号发送的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器以全流字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
## 模型体验
通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
原始进程处理位于 `src/spawn.ts``src/index.ts` 负责服务接线。

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