Merge origin/master: port the SDK stack onto the subprocess seam

Master's #660 replaced dsh-subagent-subprocess with the dsh-subprocess
capability seam (ctx.subprocess + scrubbedParentEnv, tree-scoped teardown)
and moved subagent-acp onto it. Convergence for this branch's packages:

- The shared out-of-process provider vocabulary this branch had grown in
  the deleted library (NO_START_CAPABILITIES, assertPositiveFinite, cwd
  resolution, settleRunResult, subprocessRunHandle) moves into the subagent
  seam package as out-of-process.ts — it enforces subagent-seam contracts,
  not process mechanics, and both out-of-process backends now import it
  from there (subagent-acp keeps master's shape otherwise).
- subagent-sdk spawns THROUGH the SDK client (the subprocess README's
  documented exception for SDK-managed transports) and now applies the
  seam's scrubbedParentEnv() + explicit-env merge in place of the deleted
  buildChildEnv.
- sdk-client inlines the EOF→SIGTERM→SIGKILL ladder as private helpers (it
  runs outside any harness context, so it cannot ride ctx.subprocess).
- The child harness fixture gains the now-required dsh-subprocess-local
  entry for bash-local; the fixture cordis.yml keeps exercising the
  shipped provider default.
This commit is contained in:
Tianyi Cui
2026-07-27 21:52:40 +08:00
320 changed files with 6759 additions and 4620 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: 15b05f22d5ab2ed6bdfc4f3725737d62afc8f7c1
README.zh.md: d331530f60cf584ed906553a5caa00d6a18efbc2
README.md: 8414836efd756f60258566ae3e4e00de2d4110d7
README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188

View File

@@ -10,11 +10,10 @@ 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, cwd resolution, 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`) |
| `subagent-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (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` / `subagent-sdk` backends build on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, child cwd resolution, 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` / `subagent-sdk` backends spawn their children 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,11 +10,10 @@ subagent seam 允许 agent智能体把工作委派给子 agent。与 [bash
| `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect | 无 |
| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents` |
| `subagent-subprocess/` | 共享进程外机制环境变量清理、dispose资源释放阶梯、cwd 解析、隔离配置目录(纯库;不注册任何内容) | 无 |
| `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACPAgent Client Protocol驱动的子 agent | (注册到 `ctx.subagents` |
| `subagent-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-sdk` 后端则构建于 `subagent-subprocess` 库之上凭据环境变量清理、dispose 阶梯、子进程 cwd 解析、隔离配置目录)。测试只用包内 fixture测试前置数据替换子 agent 边界。
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-sdk` 后端则经由 [`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

@@ -7,14 +7,15 @@
* @module @deepseek-ai/dsh-subagent-acp
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
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 {
@@ -66,16 +67,77 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
}
}
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
* session workspace, and a relative path would be re-anchored to the server
* process's launch directory) and an existing directory (fail here, before the
* process boundary, instead of as an ambiguous spawn ENOENT).
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
function assertUsableCwd(label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
}
if (!isDirectory(cwd)) {
throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Resolve the child's working directory: the deployment `cwd` override when
* configured (already validated at load), else the parent session's workspace
* cwd (validated here, its earliest resolvable point). Fails loud when neither
* exists — falling back to the harness process cwd would silently bind the
* child to the server's launch directory instead of the delegating session's
* workspace (one server process serves many sessions, each with its own cwd).
*/
function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
if (configured !== undefined) return configured
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
}
return assertUsableCwd('parent session cwd', parentCwd)
}
/**
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
* a request needing any of them before `start` runs).
*/
class AcpProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false
@@ -85,11 +147,12 @@ class AcpProvider implements SubagentProvider {
const spec: AcpRunSpec = {
command: this.config.command,
args: this.config.args,
cwd: resolveChildCwd('subagent-acp', this.config.cwd, request.parent.session.header.cwd),
cwd: resolveCwd(this.config.cwd, request),
permission: this.config.permission,
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.
@@ -103,13 +166,17 @@ class AcpProvider implements SubagentProvider {
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveFinite('subagent-acp', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-acp', 'disposeGraceMs', resolved.disposeGraceMs)
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
// `path.resolve('')` is the process cwd — an empty string would silently
// reintroduce the launch-directory fallback this resolution removed.
if (resolved.cwd === '') {
throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
}
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const configuredCwd = validateConfiguredCwd('subagent-acp', resolved.cwd)
const validated: ResolvedConfig = configuredCwd === undefined
const validated: ResolvedConfig = resolved.cwd === undefined
? resolved
: { ...resolved, cwd: configuredCwd }
: { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
}

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, settleRunResult, spawnFailure, subprocessRunHandle } 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) {
@@ -267,36 +329,48 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
const remoteSessionId = sessionId
// Race the remote turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.
const result: Promise<SubagentResult> = settleRunResult({
attempt: async () => {
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Race the remote turn against local cancellation.
const prompt = async (): Promise<SubagentResult> => {
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
return Promise.race([
return await Promise.race([
prompt(),
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
])
},
collectOutput,
cancelled: () => flags.cancelled,
onError: spec.onError,
signal: request.signal,
onAbort,
})
} catch (error: unknown) {
// Cover a process rejection already queued when cancellation arrives.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// Flatten post-publication transport failures while preserving diagnostics.
try {
spec.onError?.(toError(error), 'error')
} catch {
// The diagnostic sink cannot reject the run result.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
return subprocessRunHandle({
let disposal: Promise<void> | undefined
return {
id,
localAgent: undefined,
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
disposal = disposeProcess()
return disposal
},
}
}

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

@@ -33,7 +33,7 @@
"@deepseek-ai/dsh-sdk-client": "^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": {
@@ -49,7 +49,7 @@
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -13,7 +13,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_EOF_GRACE_MS,
DEFAULT_DISPOSE_GRACE_MS,

View File

@@ -3,8 +3,10 @@
* runtime over stdio JSON-RPC through `@deepseek-ai/dsh-sdk-client` and owns
* cancellation and quiescent disposal. Structure mirrors the ACP backend
* (`@deepseek-ai/dsh-subagent-acp`): publish after the child handshake,
* flatten child failures into stop reasons, tear down through the shared
* subprocess dispose ladder.
* flatten child failures into stop reasons, tear down to quiescence. The
* child is spawned BY the SDK client rather than through `ctx.subprocess` —
* the subprocess seam's documented exception for SDK-managed transports —
* so this driver applies the seam's shared env scrub itself.
*
* @module @deepseek-ai/dsh-subagent-sdk/run
*/
@@ -14,7 +16,8 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess'
import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
export interface SdkRunSpec {
@@ -34,8 +37,9 @@ export interface SdkRunSpec {
model: string
/**
* Extra environment variables to ADD for the child (e.g. the child
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged on top
* of the credential-scrubbed ambient env — see `buildChildEnv`.
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged after
* the seam's `scrubbedParentEnv()` base, so an explicit credential or
* current `DSH_*` fact survives while ambient namesakes never leak.
*/
env: Record<string, string>
/** Bound (ms) on the protocol `shutdown` exchange during dispose. */
@@ -114,7 +118,7 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
command: spec.command,
args: spec.args,
cwd: spec.cwd,
env: buildChildEnv(spec.env),
env: { ...scrubbedParentEnv(), ...spec.env },
shutdownTimeoutMs: spec.shutdownTimeoutMs,
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,

View File

@@ -36,10 +36,10 @@
"path": "../subagent"
},
{
"path": "../subagent-subprocess"
"path": "../../support/loader-smoke"
},
{
"path": "../../support/loader-smoke"
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/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,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: b41b5d2b3ca50f5c117e4a0f848c232e83e741b8
README.zh.md: 5b017f23f71405966d84f409c6d616a8a5db52a3

View File

@@ -1,63 +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) and the [SDK backend](../subagent-sdk/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, resolve the child's working directory, publish the seam run handle, and isolate the child 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.
### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd`
Child working-directory resolution, shared verbatim by the ACP and SDK backends: a configured `cwd` override is validated ONCE at load (`validateConfiguredCwd` — rejects the empty string, resolves a relative path against the harness launch directory, requires an enterable directory), and `resolveChildCwd` applies it per start, else validates the delegating parent session's cwd — never the server process's own cwd, because one server process serves many sessions. `assertUsableCwd` is the underlying probe: absolute, existing, and searchable (`X_OK` — what a subprocess cwd actually needs; a mode-600 directory passes `isDirectory()` but fails spawn with EACCES). Every diagnostic is prefixed with the consuming plugin's name.
### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle`
The provider-side skeleton every out-of-process backend shares. `NO_START_CAPABILITIES` is the frozen all-false advertisement (an out-of-process child cannot honor parent-enforced start features, so the service rejects such requests before `start`). `settleRunResult` settles the run result under the seam's never-reject contract: an attempt rejection reads as `aborted` when local cancellation already settled, else flattens to `stopReason: 'error'` through a throw-contained diagnostic sink, always removing the abort listener. `subprocessRunHandle` publishes the seam handle with idempotent dispose: remove the listener, settle local cancellation, then await the backend's teardown to actual exit.
### `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,63 +0,0 @@
# @deepseek-ai/dsh-subagent-subprocess
[English](README.md) | 中文
用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent智能体作为子进程派生例如 [ACP 后端](../subagent-acp/README.md)和 [SDK 后端](../subagent-sdk/README.md)。这是纯库(无提供方、无注册、无 Config提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳、解析子进程工作目录、发布接缝 run 句柄,以及将子进程与宿主用户的磁盘 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 和监听器,因此升级过程不会在子进程上累积监听器。
### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd`
子进程工作目录解析,被 ACP 与 SDK 后端逐字共享:配置的 `cwd` 覆盖在加载时校验一次(`validateConfiguredCwd`——拒绝空字符串、把相对路径按 harness 启动目录解析、要求可进入的目录),`resolveChildCwd` 在每次 start 应用它,否则校验发起委托的父会话 cwd——绝不用服务器进程自己的 cwd因为一个服务器进程服务多个会话。`assertUsableCwd` 是底层探针:绝对、存在且可搜索(`X_OK`——子进程 cwd 真正需要的权限mode-600 目录能过 `isDirectory()` 却让 spawn 以 EACCES 失败)。所有诊断都带消费插件名前缀。
### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle`
每个进程外后端共享的 provider 侧骨架。`NO_START_CAPABILITIES` 是冻结的全 false 能力宣告(进程外子进程无法执行父方强制的启动期特性,服务会在 `start` 之前拒绝此类请求)。`settleRunResult` 在接缝的绝不拒绝契约下定格 run 结果:尝试的拒绝在本地取消已定格时读作 `aborted`,否则经吞掉自身异常的诊断汇压平为 `stopReason: 'error'`,并总是移除 abort 监听器。`subprocessRunHandle` 发布幂等 dispose 的接缝句柄:移除监听器、定格本地取消,然后等待后端的拆除直至真正退出。
### `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,42 +0,0 @@
{
"name": "@deepseek-ai/dsh-subagent-subprocess",
"description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,86 +0,0 @@
/**
* Child working-directory resolution shared by out-of-process subagent
* backends: a deployment `cwd` override validated at load, else the
* delegating parent session's workspace cwd validated per start — never the
* server process's own cwd, because one server process serves many sessions,
* each with its own workspace.
*
* @module @deepseek-ai/dsh-subagent-subprocess/cwd
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the
* child's workspace identity, and a relative path would be re-anchored to the
* server process's launch directory) and an existing directory (fail here,
* before the process boundary, instead of as an ambiguous spawn ENOENT).
* @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
export function assertUsableCwd(prefix: string, label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`)
}
if (!isDirectory(cwd)) {
throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Validate a configured `cwd` override ONCE, at plugin load: reject the empty
* string (`path.resolve('')` is the process cwd — it would silently
* reintroduce the launch-directory fallback this resolution removes),
* interpret a relative path against the harness launch directory, and require
* an enterable directory.
* @param prefix - the consuming plugin's diagnostic prefix.
* @param cwd - the configured override, or `undefined` when the config omits it.
* @returns the validated absolute override, or `undefined` when omitted.
*/
export function validateConfiguredCwd(prefix: string, cwd: string | undefined): string | undefined {
if (cwd === undefined) return undefined
if (cwd === '') {
throw new Error(`${prefix}: config cwd must not be empty — omit the key to inherit the parent session cwd`)
}
return assertUsableCwd(prefix, 'config cwd', resolve(cwd))
}
/**
* Resolve the child's working directory at start: the deployment override
* when configured (already validated at load), else the parent session's
* workspace cwd (validated here, its earliest resolvable point). Fails loud
* when neither exists.
* @param prefix - the consuming plugin's diagnostic prefix.
* @param configured - the load-validated override, or `undefined`.
* @param parentCwd - the delegating parent session's workspace cwd, if any.
* @returns the absolute child working directory.
*/
export function resolveChildCwd(prefix: string, configured: string | undefined, parentCwd: string | undefined): string {
if (configured !== undefined) return configured
if (parentCwd === undefined) {
throw new Error(`${prefix}: no working directory for the child — configure \`cwd\` or delegate from a parent session that has one`)
}
return assertUsableCwd(prefix, 'parent session cwd', parentCwd)
}

View File

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

View File

@@ -1,494 +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,
NO_START_CAPABILITIES,
settleRunResult,
spawnFailure,
subprocessRunHandle,
} from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
// `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 })
}
})
})
describe('NO_START_CAPABILITIES', () => {
it('advertises nothing and is frozen (shared by every out-of-process backend)', () => {
expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true)
})
})
describe('settleRunResult', () => {
const wiring = () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
return { controller, onAbort }
}
it('passes a successful attempt through and removes the abort listener', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }),
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('completed')
controller.abort()
// The listener was removed at settlement, so the abort never reaches it.
expect(onAbort).not.toHaveBeenCalled()
})
it('reads an in-flight rejection as aborted when cancellation already settled', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('pipe torn mid-cancel') },
collectOutput: () => [{ type: 'text', text: 'partial' }],
cancelled: () => true,
signal: controller.signal,
onAbort,
})
expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' })
})
it('flattens a failure through a contained onError sink', async () => {
const { controller, onAbort } = wiring()
const seen: string[] = []
const result = await settleRunResult({
attempt: async () => { throw new Error('transport died') },
collectOutput: () => [],
cancelled: () => false,
onError: (error, stopReason) => {
seen.push(`${stopReason}:${error.message}`)
throw new Error('sink failure must be contained')
},
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
expect(seen).toEqual(['error:transport died'])
})
it('flattens a failure without a sink', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('no sink configured') },
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
})
})
describe('subprocessRunHandle', () => {
it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
const requestCancel = vi.fn()
const teardown = vi.fn(() => Promise.resolve())
const run = subprocessRunHandle({
id: SessionId('run-1'),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
signal: controller.signal,
onAbort,
requestCancel,
teardown,
})
expect(run.localAgent).toBeUndefined()
expect(String(run.id)).toBe('run-1')
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
expect(requestCancel).toHaveBeenCalledTimes(1)
expect(teardown).toHaveBeenCalledTimes(1)
controller.abort()
// dispose removed the abort listener before cancelling.
expect(onAbort).not.toHaveBeenCalled()
})
})

View File

@@ -1,21 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../llm/llm"
},
{
"path": "../subagent"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -46,6 +46,7 @@ import type {
} from './types.ts'
import { SubagentRunId } from './types.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
export type {
SubagentCapabilities,

View File

@@ -1,14 +1,20 @@
/**
* Shared provider-side vocabulary for out-of-process subagent backends: the
* no-capabilities advertisement, timing-bound validation for the dispose
* ladder's graces, and the standard run-handle publication that owns dispose
* idempotence and abort-listener hygiene.
* Provider-side vocabulary for OUT-OF-PROCESS subagent backends the pieces
* that enforce this seam's own contracts around a child in another process:
* the no-capabilities advertisement, timing-bound validation, child
* working-directory resolution (config override, else the delegating parent
* session's workspace), the never-reject result settlement, and the standard
* run-handle publication. Backends compose these with their own wire drivers;
* the process machinery itself (spawn, env scrub, tree-scoped teardown)
* belongs to the `dsh-subprocess` seam.
*
* @module @deepseek-ai/dsh-subagent-subprocess/provider
* @module @deepseek-ai/dsh-subagent/out-of-process
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts'
/**
* The capability advertisement of an out-of-process backend: NONE. A child in
@@ -36,9 +42,86 @@ export function assertPositiveFinite(prefix: string, name: string, value: number
}
}
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isEnterableDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the
* child's workspace identity, and a relative path would be re-anchored to the
* server process's launch directory) and an existing directory (fail here,
* before the process boundary, instead of as an ambiguous spawn ENOENT).
* @param prefix - the consuming plugin's diagnostic prefix.
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
export function assertUsableCwd(prefix: string, label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`)
}
if (!isEnterableDirectory(cwd)) {
throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Validate a configured `cwd` override ONCE, at plugin load: reject the empty
* string (`path.resolve('')` is the process cwd it would silently
* reintroduce the launch-directory fallback this resolution removes),
* interpret a relative path against the harness launch directory, and require
* an enterable directory.
* @param prefix - the consuming plugin's diagnostic prefix.
* @param cwd - the configured override, or `undefined` when the config omits it.
* @returns the validated absolute override, or `undefined` when omitted.
*/
export function validateConfiguredCwd(prefix: string, cwd: string | undefined): string | undefined {
if (cwd === undefined) return undefined
if (cwd === '') {
throw new Error(`${prefix}: config cwd must not be empty — omit the key to inherit the parent session cwd`)
}
return assertUsableCwd(prefix, 'config cwd', resolve(cwd))
}
/**
* Resolve the child's working directory at start: the deployment override
* when configured (already validated at load), else the parent session's
* workspace cwd (validated here, its earliest resolvable point). Fails loud
* when neither exists falling back to the harness process cwd would
* silently bind the child to the server's launch directory instead of the
* delegating session's workspace (one server process serves many sessions,
* each with its own cwd).
* @param prefix - the consuming plugin's diagnostic prefix.
* @param configured - the load-validated override, or `undefined`.
* @param parentCwd - the delegating parent session's workspace cwd, if any.
* @returns the absolute child working directory.
*/
export function resolveChildCwd(prefix: string, configured: string | undefined, parentCwd: string | undefined): string {
if (configured !== undefined) return configured
if (parentCwd === undefined) {
throw new Error(`${prefix}: no working directory for the child — configure \`cwd\` or delegate from a parent session that has one`)
}
return assertUsableCwd(prefix, 'parent session cwd', parentCwd)
}
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
function toError(value: unknown): Error {
// The rejecting surfaces (wire clients, spawn error events) only throw
// The rejecting surfaces (wire clients, spawn failures) only throw
// `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
// throw the typed surfaces cannot produce.
/* v8 ignore next */

View File

@@ -0,0 +1,174 @@
/**
* Unit coverage for the seam's out-of-process provider vocabulary: cwd
* resolution against the real filesystem, and the settlement/handle helpers
* under their never-reject and idempotence contracts.
*/
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
assertPositiveFinite,
assertUsableCwd,
NO_START_CAPABILITIES,
resolveChildCwd,
settleRunResult,
subprocessRunHandle,
validateConfiguredCwd,
} from '../src/index.ts'
describe('NO_START_CAPABILITIES', () => {
it('advertises nothing and is frozen (shared by every out-of-process backend)', () => {
expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true)
})
})
describe('assertPositiveFinite', () => {
it('accepts positive finite bounds and rejects zero, negatives, and NaN', () => {
expect(() => { assertPositiveFinite('p', 'graceMs', 1) }).not.toThrow()
expect(() => { assertPositiveFinite('p', 'graceMs', 0) }).toThrow('p: graceMs must be a positive finite number')
expect(() => { assertPositiveFinite('p', 'graceMs', -5) }).toThrow('positive finite')
expect(() => { assertPositiveFinite('p', 'graceMs', Number.NaN) }).toThrow('positive finite')
expect(() => { assertPositiveFinite('p', 'graceMs', Number.POSITIVE_INFINITY) }).toThrow('positive finite')
})
})
describe('child cwd resolution', () => {
it('accepts an absolute enterable directory and rejects relative or missing paths', () => {
expect(assertUsableCwd('p', 'config cwd', tmpdir())).toBe(tmpdir())
expect(() => assertUsableCwd('p', 'config cwd', 'relative/path')).toThrow('must be an absolute path')
expect(() => assertUsableCwd('p', 'config cwd', join(tmpdir(), 'dsh-no-such-dir-xyz'))).toThrow('not an accessible directory')
})
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
it.skipIf(process.platform === 'win32')('rejects a directory without search permission', () => {
// statSync().isDirectory() is true for a mode-600 directory, but a
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
const tmp = mkdtempSync(join(tmpdir(), 'oop-noexec-'))
chmodSync(tmp, 0o600)
try {
expect(() => assertUsableCwd('p', 'config cwd', tmp)).toThrow('not an accessible directory')
} finally {
chmodSync(tmp, 0o700)
rmSync(tmp, { recursive: true, force: true })
}
})
it('validateConfiguredCwd: undefined passes through, empty fails, relative resolves at load', () => {
expect(validateConfiguredCwd('p', undefined)).toBeUndefined()
expect(() => validateConfiguredCwd('p', '')).toThrow('config cwd must not be empty')
const tmp = mkdtempSync(join(tmpdir(), 'oop-rel-'))
try {
const relativeCwd = relative(process.cwd(), tmp)
// Resolution is lexical against the launch directory; the probe then
// requires the resolved path to exist and be enterable.
expect(validateConfiguredCwd('p', relativeCwd)).toBe(resolve(relativeCwd))
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('resolveChildCwd: override wins, else the parent session cwd validates, else loud failure', () => {
expect(resolveChildCwd('p', tmpdir(), undefined)).toBe(tmpdir())
expect(resolveChildCwd('p', undefined, tmpdir())).toBe(tmpdir())
expect(() => resolveChildCwd('p', undefined, undefined)).toThrow('no working directory for the child')
expect(() => resolveChildCwd('p', undefined, 'relative/parent')).toThrow('parent session cwd must be an absolute path')
})
})
describe('settleRunResult', () => {
const wiring = () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
return { controller, onAbort }
}
it('passes a successful attempt through and removes the abort listener', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }),
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('completed')
controller.abort()
// The listener was removed at settlement, so the abort never reaches it.
expect(onAbort).not.toHaveBeenCalled()
})
it('reads an in-flight rejection as aborted when cancellation already settled', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('pipe torn mid-cancel') },
collectOutput: () => [{ type: 'text', text: 'partial' }],
cancelled: () => true,
signal: controller.signal,
onAbort,
})
expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' })
})
it('flattens a failure through a contained onError sink', async () => {
const { controller, onAbort } = wiring()
const seen: string[] = []
const result = await settleRunResult({
attempt: async () => { throw new Error('transport died') },
collectOutput: () => [],
cancelled: () => false,
onError: (error, stopReason) => {
seen.push(`${stopReason}:${error.message}`)
throw new Error('sink failure must be contained')
},
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
expect(seen).toEqual(['error:transport died'])
})
it('flattens a failure without a sink', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('no sink configured') },
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
})
})
describe('subprocessRunHandle', () => {
it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
const requestCancel = vi.fn()
const teardown = vi.fn(() => Promise.resolve())
const run = subprocessRunHandle({
id: SessionId('run-1'),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
signal: controller.signal,
onAbort,
requestCancel,
teardown,
})
expect(run.localAgent).toBeUndefined()
expect(String(run.id)).toBe('run-1')
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
expect(requestCancel).toHaveBeenCalledTimes(1)
expect(teardown).toHaveBeenCalledTimes(1)
controller.abort()
// dispose removed the abort listener before cancelling.
expect(onAbort).not.toHaveBeenCalled()
})
})