Merge branch 'worktree-subprocess-consumers' into worktree-process-service-seam

This commit is contained in:
Tianyi Cui
2026-07-27 00:22:29 +08:00
87 changed files with 1935 additions and 1665 deletions
+47 -13
View File
@@ -11,8 +11,8 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
@@ -54,6 +54,16 @@ export interface Config {
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
const read = reader.readFrom(0)
return {
text: read.text,
truncated: read.lossy,
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
}
}
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`bash-local: ${name} must be a positive finite number`)
@@ -127,36 +137,60 @@ export class LocalBashExecutor extends BashExecutor {
}
}
/** Map one resolved bash spec onto a fully-specified process spawn. */
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: ['bash', '-c', spec.command],
cwd: spec.workdir,
stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
stdio: {
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
stdout: collect(stdoutMaxBytes),
stderr: collect(this.config.maxOutputBytes),
},
graceMs: this.config.graceMs,
signal,
stdin: spec.stdin,
env: { ...ENV_OVERRIDES, ...spec.env },
dshEnv: spec.dshEnv,
}
}
/** The collect-mode readers the executor itself requested (present by construction). */
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
const { stdout, stderr } = handle.collected
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
if (stdout === undefined || stderr === undefined) {
throw new Error('bash-local: subprocess implementation dropped a requested collect stream')
}
/* v8 ignore stop */
return { stdout, stderr }
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
return {
...outcome,
timedOut,
aborted,
timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout),
stderr: finalOutput(collected.stderr),
}
}
start(spec: BashExecSpec): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
// to buffer; the note is delivered exactly once through the read path.
@@ -180,7 +214,7 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
@@ -188,8 +222,8 @@ export class LocalBashExecutor extends BashExecutor {
this.onProcessDone(proc, spawnFailureNote)
}),
readOutput: (): BashProcessRead => {
const out = running.stdout.readFrom(stdoutOffset)
const err = running.stderr.readFrom(stderrOffset)
const out = collected.stdout.readFrom(stdoutOffset)
const err = collected.stderr.readFrom(stderrOffset)
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
@@ -211,7 +245,7 @@ export class LocalBashExecutor extends BashExecutor {
kill: (): boolean => {
if (proc.status !== 'running') return false
proc.status = 'killed'
running.kill()
running.terminate()
return true
},
}
+28 -4
View File
@@ -750,7 +750,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */',
},
],
},
@@ -2206,13 +2206,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentStopReasonMap',
declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}',
},
{
name: 'SubprocessCollect',
declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}',
},
{
name: 'SubprocessCollectedOutputs',
declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}',
},
{
name: 'SubprocessDisposeGraces',
declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}',
},
{
name: 'SubprocessHandle',
declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise<SubprocessOutcome>;\n kill(): void;\n}',
declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise<SubprocessOutcome>;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise<boolean>;\n dispose(graces: SubprocessDisposeGraces): Promise<void>;\n}',
},
{
name: 'SubprocessOutcome',
declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}',
},
{
name: 'SubprocessOutputMode',
declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;',
},
{
name: 'SubprocessOutputRead',
@@ -2224,7 +2240,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubprocessSpawnSpec',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
},
{
name: 'SubprocessStdinMode',
declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};',
},
{
name: 'SubprocessStdio',
declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}',
},
{
name: 'SurfaceEvent',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555
README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877
README.md: 462cb12ce96dbbb645c9a19126911d32d4ddd722
README.zh.md: f5537a416c49106b128188efe4adb2d65304320a
+1 -1
View File
@@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
+1 -1
View File
@@ -12,7 +12,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
- 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。
- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认
- 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
+3
View File
@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -42,6 +43,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7",
"typescript": "^6.0.3",
+58 -188
View File
@@ -1,16 +1,17 @@
/**
* A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
* requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
* from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
* commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
* child handle so the instance owns process-signal teardown.
* A JSON-RPC endpoint over one language server spawned through the subprocess
* seam. Owns id correlation, outbound requests/notifications, and inbound
* server→client requests: it answers `workspace/configuration` from static
* config, and rejects `workspace/applyEdit` (this host never applies edits or
* runs commands). It caps stderr, surfaces framing/decoder failures as a
* fatal close, and exposes tree-scoped termination through the handle so the
* instance owns teardown; group/tree mechanics live in the seam's
* implementation.
* @module @deepseek-ai/dsh-lsp-local/connection
*/
import type { ChildProcessByStdio } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { Writable } from 'node:stream'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { encodeMessage, MessageDecoder } from './framing.ts'
/** How to launch the server and answer its config requests. */
@@ -27,6 +28,12 @@ export interface ConnectionSpec {
readonly maxMessageBytes: number
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
/**
* Bound (ms) for draining pipes a surviving helper still holds after the
* server exits; the instance passes its kill grace so exit observation is
* never slower than the escalation it feeds.
*/
readonly pipeDrainGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
@@ -48,178 +55,89 @@ export type ConnectionWriter = (
done: (error?: Error | null) => void,
) => void
/** Host operations used to signal a detached process tree. */
export interface ProcessTreeOperations {
/** Signal a POSIX process group. */
readonly signal: (target: number, signal: NodeJS.Signals) => void
/** Signal the direct child when POSIX group signaling is unavailable. */
readonly killChild: (signal: NodeJS.Signals) => void
/** Terminate a Windows process tree by root pid. */
readonly taskkill: (pid: number) => void
}
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
export interface TaskkillResult {
/** Process exit status, or null when spawning failed. */
readonly status: number | null
/** Spawn failure, when the executable could not run. */
readonly error?: Error
}
/** Invoke a command synchronously for the Windows taskkill adapter. */
export type TaskkillRunner = (
command: string,
args: string[],
options: { stdio: 'ignore' },
) => TaskkillResult
/** Invoke the host process-signal primitive for a POSIX process group. */
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
/** taskkill status for "process not found": the requested process tree is already absent. */
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */
export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
/**
* Terminate one Windows process tree and wait for taskkill to finish.
* @param pid - root process id.
* @param run - command runner; tests inject results without requiring Windows.
*/
export function taskkillProcessTree(
pid: number,
run: TaskkillRunner = spawnSync,
): void {
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
if (result.error !== undefined) throw result.error
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
}
/**
* Signal one POSIX process group through an injectable host primitive.
* @param target - negative process-group id.
* @param signal - requested signal.
* @param run - host signal runner; tests inject it without touching real processes.
*/
export function signalProcessGroup(
target: number,
signal: NodeJS.Signals,
run: ProcessSignalRunner = processSignalRunner,
): void {
run(target, signal)
}
/**
* Wait until a process-tree liveness probe reports exit.
* @param isAlive - process-tree liveness probe.
* @param signal - optional bound for the wait.
* @param yieldNow - event-loop yield primitive.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
export async function waitForTreeExit(
isAlive: () => boolean,
signal?: AbortSignal,
yieldNow: () => Promise<unknown> = yieldToEventLoop,
): Promise<boolean> {
while (isAlive()) {
if (signal?.aborted) return false
await yieldNow()
}
return true
}
/**
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
* child; Windows requires taskkill to reach the full tree.
* @param platform - host platform.
* @param pid - detached root process id.
* @param signal - requested termination signal.
* @param operations - host operations.
*/
export function signalProcessTree(
platform: NodeJS.Platform,
pid: number,
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
operations.signal(-pid, signal)
} catch {
try {
operations.killChild(signal)
} catch {
// The direct child already exited; teardown remains idempotent.
}
}
}
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
private readonly handle: SubprocessHandle
private readonly stdin: Writable
private readonly decoder: MessageDecoder
private readonly pending = new Map<number, Pending>()
private nextId = 1
private stderr = Buffer.alloc(0)
private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise<void>
/**
* @param spec - how to launch the server and answer its config requests.
* @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`).
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
private readonly spec: ConnectionSpec,
spec: ConnectionSpec,
spawner: ConnectionSpawner,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
this.child = spawn(spec.command, [...spec.args], {
// stdin/stdout are piped protocol streams this endpoint frames itself;
// stderr is a collected diagnostic tail (no spill — the bounded tail IS
// the contract). The seam owns detachment and tree-scoped signalling.
this.handle = spawner({
argv: [spec.command, ...spec.args],
cwd: spec.cwd,
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.pipeDrainGraceMs,
env: spec.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (this.handle.stdin === undefined || this.handle.stdout === undefined) {
throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream')
}
/* v8 ignore stop */
this.stdin = this.handle.stdin
this.closed = new Promise<void>((resolve) => {
this.child.on('close', () => {
const close = (): void => {
const reason = this.closeReason ?? new Error(this.exitMessage())
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
// (a closed process sends no further responses).
this.closeReason = reason
this.failAll(reason)
resolve()
}
this.handle.done.then(close, (error: unknown) => {
// A spawn-level failure never produces a close event; the rejection is
// the fatal cause and the close boundary at once.
this.fail(asError(error))
close()
})
})
this.child.on('error', (error) => { this.fail(error) })
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
// waiting for a process-close event that may never arrive.
this.child.stdin.on('error', (error) => { this.fail(error) })
this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
this.stdin.on('error', (error) => { this.fail(error) })
this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
}
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
get pid(): number {
/* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
return this.child.pid ?? -1
return this.handle.pid
}
/** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string {
return this.stderr.toString('utf8')
/* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */
return this.handle.collected.stderr?.readFrom(0).text ?? ''
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
@@ -289,14 +207,14 @@ export class LspConnection {
return this.nextId
}
/** Request termination of the server's process tree. */
/** Request termination of the server's process tree (SIGTERM, no escalation). */
terminate(): void {
this.signalTree('SIGTERM')
this.handle.kill('SIGTERM')
}
/** Force termination of the server's process tree. */
kill(): void {
this.signalTree('SIGKILL')
this.handle.kill('SIGKILL')
}
/**
@@ -305,39 +223,7 @@ export class LspConnection {
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
}
/** Signal the whole process tree. */
private signalTree(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
signalProcessTree(process.platform, pid, sig, {
signal: signalProcessGroup,
killChild: this.child.kill.bind(this.child),
taskkill: taskkillProcessTree,
})
}
/** Whether the detached tree's root or POSIX process group is still alive. */
private processTreeAlive(): boolean {
const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false
try {
process.kill(-pid, 0)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
/* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes
whether lifecycle tests observe this branch platform-dependent. */
if (code === 'ESRCH') return false
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */
if (code === 'EPERM') return true
return this.child.exitCode === null && this.child.signalCode === null
/* v8 ignore stop */
}
return await this.handle.waitForExit(signal)
}
private onStdout(chunk: Buffer): void {
@@ -348,28 +234,12 @@ export class LspConnection {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error))
this.signalTree('SIGKILL')
this.handle.kill('SIGKILL')
return
}
for (const message of messages) this.dispatch(message)
}
private onStderr(chunk: Buffer): void {
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
// before it exits, so the final bounded segment is the useful one.
const cap = this.spec.maxStderrBytes
if (chunk.length >= cap) {
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
return
}
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
this.stderr = Buffer.concat([
this.stderr.subarray(this.stderr.length - retainedBytes),
chunk,
], retainedBytes + chunk.length)
}
private dispatch(message: unknown): void {
if (message === null || typeof message !== 'object') return
const frame = message as Record<string, unknown>
@@ -423,7 +293,7 @@ export class LspConnection {
reject(error)
}
try {
this.writer(this.child.stdin, message, done)
this.writer(this.stdin, message, done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {
+11 -9
View File
@@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import { LspInstance } from './instance.ts'
import type { ConnectionSpawner } from './connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts'
export const name = 'lsp-local'
/** Services required by this plugin. */
export const inject = ['lsp']
export const inject = ['lsp', 'subprocess']
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
@@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void {
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
return new LocalLspProvider(providerId, resolved, childEnv, executable)
return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
})
ctx.effect(() => {
@@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider {
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
private readonly spawner: ConnectionSpawner,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
@@ -282,10 +285,12 @@ class LocalLspProvider implements LspProvider {
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
// Exit observation must never be slower than the escalation it feeds.
pipeDrainGraceMs: this.config.killGraceMs,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}
return new LspInstance(spec)
return new LspInstance(spec, this.spawner)
}
/** Dispose every live instance and block further queries. */
@@ -302,12 +307,9 @@ class LocalLspProvider implements LspProvider {
}
}
/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
const scrubbed = Object.entries(process.env).filter(
([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
) as [string, string][]
return { ...Object.fromEntries(scrubbed), ...extra }
return { ...scrubbedParentEnv(), ...extra }
}
/**
+4 -3
View File
@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
@@ -67,10 +67,11 @@ export class LspInstance {
/**
* @param spec - the launch, initialize, and teardown parameters.
* @param spawner - the subprocess seam's spawn function.
* @param writer - optional connection writer used by transport conformance tests.
*/
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
@@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const { Context } = await import('cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
fake: {
+12 -84
View File
@@ -1,18 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import {
signalProcessGroup,
signalProcessTree,
taskkillProcessTree,
waitForTreeExit,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type {
ConnectionWriter,
ProcessSignalRunner,
ProcessTreeOperations,
TaskkillRunner,
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -39,11 +30,12 @@ function connect(
command: process.execPath,
args: [fixtureServer],
cwd: process.cwd(),
env: { ...process.env as Record<string, string>, ...env },
env: { ...scrubbedParentEnv(), ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 3_000,
configuration: { setting: 42 },
}, (method, params) => {
}, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
return onServerRequest(method, params)
})
@@ -148,11 +140,12 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec
command: process.execPath,
args: ['-e', script],
cwd: process.cwd(),
env: { ...process.env as Record<string, string> },
env: scrubbedParentEnv(),
maxMessageBytes: 16_000_000,
maxStderrBytes,
pipeDrainGraceMs: 3_000,
configuration: null,
}, () => Promise.resolve(null), writer)
}, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
@@ -166,8 +159,9 @@ describe('LspConnection edge behavior', () => {
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
pipeDrainGraceMs: 3_000,
configuration: null,
}, () => Promise.resolve(null))
}, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)
await expect(conn.request('initialize', {})).rejects.toThrow()
})
@@ -248,72 +242,6 @@ describe('LspConnection edge behavior', () => {
})
})
describe('process-tree signaling', () => {
it('forwards POSIX process-group signals through the host runner', () => {
const run: ProcessSignalRunner = vi.fn(() => true)
signalProcessGroup(-42, 'SIGKILL', run)
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('waits for tree exit and stops when its bound aborts', async () => {
const isAlive = vi.fn()
.mockReturnValueOnce(true)
.mockReturnValue(false)
const yieldNow = vi.fn(() => Promise.resolve())
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
expect(yieldNow).toHaveBeenCalledOnce()
const controller = new AbortController()
controller.abort()
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
})
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
const operations = fakeProcessTreeOperations()
signalProcessTree('win32', 42, 'SIGTERM', operations)
expect(operations.taskkill).toHaveBeenCalledWith(42)
expect(operations.signal).not.toHaveBeenCalled()
signalProcessTree('linux', 42, 'SIGKILL', operations)
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
})
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
const fallback = fakeProcessTreeOperations()
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
expect(fallback.killChild).not.toHaveBeenCalled()
})
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
const posixGone = fakeProcessTreeOperations()
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
})
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
taskkillProcessTree(42, success)
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
const spawnFailure = new Error('cannot spawn taskkill')
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
})
})
/** Create observable process-tree operations without touching host processes. */
function fakeProcessTreeOperations(): ProcessTreeOperations {
return {
signal: vi.fn(),
killChild: vi.fn(),
taskkill: vi.fn(),
}
}
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()
@@ -9,6 +9,8 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -38,15 +40,16 @@ function makeInstance(
command: process.execPath,
args: [fixtureServer],
cwd: ws,
env: { ...process.env as Record<string, string>, ...env },
env: { ...scrubbedParentEnv(), ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 200,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
}, writer)
}, spawnSubprocess, writer)
live.push(instance)
return instance
}
@@ -67,15 +70,16 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
command: process.execPath,
args: ['-e', script],
cwd: ws,
env: { ...process.env as Record<string, string> },
env: scrubbedParentEnv(),
configuration: null,
initializationOptions: null,
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 150,
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
})
}, spawnSubprocess)
live.push(instance)
return instance
}
@@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -45,6 +46,7 @@ async function mount(
): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
@@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => {
await writeFile(join(ws, 'a.py'), 'x = 1\n')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
@@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => {
it('rejects at load when the command is not found', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {
@@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
@@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => {
it('skips empty PATH segments and fails when the command is absent', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
@@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => {
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
@@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
@@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
@@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => {
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
@@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => {
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
@@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
@@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server table at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
@@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server id at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
@@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => {
it('resolves every executable before publishing any provider', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => {
it('rolls back earlier registrations when a later server conflicts', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -52,6 +53,7 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: {
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../lsp"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
@@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
inline: {
+2
View File
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -40,6 +41,7 @@
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@modelcontextprotocol/server-everything": "^2026.7.4",
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
+6 -11
View File
@@ -9,22 +9,17 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { Config } from './index.ts'
/**
* Credential-shaped ambient env vars are NOT forwarded to the child by default
* (the parent harness's own secrets must not leak into a spawned process
* implicitly). Same pattern as `dsh-subagent-acp`.
* The subprocess seam's scrubbed parent env (credential-shaped and stale
* `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
* actual spawn, so this transport shares the scrub definition rather than the
* spawn path.
*/
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...extra }
return { ...scrubbedParentEnv(), ...extra }
}
/**
+3
View File
@@ -21,6 +21,9 @@
{
"path": "../../core/tools"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}
+2
View File
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+3 -6
View File
@@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -26,7 +27,6 @@ export const name = 'pty-local'
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
interface SandboxModeFenceState {
pty: Context['pty']
sandboxPolicy: Context['sandboxPolicy']
@@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
}
// node-pty owns the spawn; the base env shares the subprocess seam's scrub.
return {
...env,
...scrubbedParentEnv(),
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}
+2
View File
@@ -35,6 +35,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -44,6 +45,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
@@ -5,6 +5,7 @@
*/
import { execFile, spawn } from 'node:child_process'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { promisify } from 'node:util'
import type { PackageJsonFile } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
@@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
}
}
/** Remove credential-shaped environment variables from spawned commands. */
export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
/**
* Remove credential-shaped environment variables from spawned commands.
* @param environment - source environment (injectable for tests); the default
* path shares the subprocess seam's scrub so every harness spawner drops the
* same names.
*/
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (environment === undefined) return scrubbedParentEnv()
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
}
+3
View File
@@ -33,6 +33,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149
README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354
README.md: 8414836efd756f60258566ae3e4e00de2d4110d7
README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188
+1 -2
View File
@@ -10,10 +10,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures.
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md).
+1 -2
View File
@@ -10,10 +10,9 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash
| `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 |
| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents` |
| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 |
| `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACPAgent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。
@@ -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: 317517f64f24d8a3ed01ebae08dcfd13668b9029
README.zh.md: e10f435b13e7cdabb92ebfa5b4a5d0763f2af18f
+1 -1
View File
@@ -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 are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), 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).
+1 -1
View File
@@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
## 进程边界
子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env` 值。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
子进程由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env`在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。
+3 -2
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"
}
}
+2 -1
View File
@@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
export const inject = ['subagents']
export const inject = ['subagents', 'subprocess']
/** Config: how to spawn and drive the child ACP agent process. */
export interface Config {
@@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider {
env: this.config.env,
disposeEofGraceMs: this.config.disposeEofGraceMs,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spec => this.ctx.subprocess.spawn(spec),
onError: (error, stopReason) => {
// The seam forbids `result` rejecting, so a child-level failure is
// flattened to a stop reason — preserve it here rather than losing it.
+40 -18
View File
@@ -8,9 +8,8 @@
* @module @deepseek-ai/dsh-subagent-acp/run
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { Readable, Writable } from 'node:stream'
import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream'
import {
ClientSideConnection,
ndJsonStream,
@@ -26,7 +25,7 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
@@ -47,9 +46,9 @@ 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).
*/
env: Record<string, string>
/**
@@ -65,6 +64,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
@@ -159,20 +164,37 @@ 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 while spec.env
// (the child's own key) 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 ??= child.dispose({
eofGraceMs: spec.disposeEofGraceMs,
graceMs: spec.disposeGraceMs,
}))
// Accumulate the child's streamed assistant text — the SubagentResult output.
@@ -207,8 +229,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 +274,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) {
@@ -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,
@@ -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 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,19 +100,23 @@ 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
}
})
})
@@ -140,6 +146,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 +165,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 +193,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 +213,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 +234,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 +253,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 +295,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 +373,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 +398,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 +426,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 +474,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 +508,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 +604,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 +618,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 +644,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 +654,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 +681,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 +720,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 +785,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 +795,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)
+1 -1
View File
@@ -27,7 +27,7 @@
"path": "../subagent"
},
{
"path": "../subagent-subprocess"
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/loader-smoke"
@@ -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: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb
README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740
@@ -1,55 +0,0 @@
# @deepseek-ai/dsh-subagent-subprocess
English | [中文](README.zh.md)
Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md).
Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library.
## What it exports
### `buildChildEnv(extra)`
The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child.
### `spawnFailure(child)`
Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles.
### `disposeChildProcess(child, graces)`
The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact;
2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`;
3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal.
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush.
The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
### `createIsolatedConfigDir(prefix, pinnedPath?)`
A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose.
- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent.
- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle.
## Testing
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end.
## Model Experience
Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment.
- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder.
- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal.
- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state.
@@ -1,55 +0,0 @@
# @deepseek-ai/dsh-subagent-subprocess
[English](README.md) | 中文
用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent(智能体)作为子进程派生,例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config),提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。
每个可调项都是**参数**:dispose(资源释放)阶梯每次调用时接收宽限时间,配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。
## 导出内容
### `buildChildEnv(extra)`
凭据环境变量清理采用与 [bash 执行器](../../bash/bash-local/README.md)相同的模式:子进程环境等于环境继承值移除名称形似凭据的变量(`/KEY|SECRET|TOKEN/i`)后,再把 `extra` 叠加到清理结果之后。`PATH``HOME``TMPDIR`、locale 和代理变量会保留,使子 CLI 正常运行;父级自身的秘密绝不会隐式泄漏,而显式提供的凭据(后端 `env` 配置中子进程自己的密钥)仍会传给子进程。
### `spawnFailure(child)`
派生失败捕获:返回一个 promise,它会以子进程的第一个 `error` 事件兑现(绝不拒绝)。`ENOENT` 等派生失败是事件而非抛出的异常;没有监听器时 Node 会使父进程崩溃。因此,请在调用 `spawn()` 的同一个 tick 内调用此函数,并在运行结果路径中将其纳入竞速;错误命令随后会作为普通的子进程级失败结算。对于正常派生的子进程,该 promise 永不结算。
### `disposeChildProcess(child, graces)`
平台感知的 dispose 阶梯只会在子进程确实退出后兑现:达到完全停稳,而不只是发出请求(见[防御性模式](../../../docs/defensive-patterns.md)):
1. stdin EOF(如果 stdin 已建立管道),然后等待 `graces.disposeEofGraceMs`:可协作的子进程自行完全停稳,同时保留其 flush 与嵌套子进程清理;
2. 在 POSIX 上发送 `SIGTERM`,然后等待 `graces.disposeGraceMs`
3. 强制终止:POSIX 使用 `SIGKILL`Windows 使用 Node 映射的 `TerminateProcess`;然后最多等待 `graces.disposeGraceMs` 以确认退出。信号错误或未退出会导致 dispose 拒绝。
两个宽限时间(`DisposeLadderGraces`)来自消费插件的 `disposeEofGraceMs`/`disposeGraceMs` Config 字段。POSIX 在优雅信号和强制信号之后都使用 `disposeGraceMs`;Windows 跳过冗余的优雅信号,但用该值限定强制退出确认时间。EOF 窗口有意独立设置且通常更宽,因为协作式清理可能要等待捕获信号的孙进程和最后一次 flush。
退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。
### `createIsolatedConfigDir(prefix, pinnedPath?)`
为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。
- **全新(默认)**:OS 临时根目录下的私有(0700)`mkdtemp` 目录;`remove()` 会尽力删除它,且绝不拒绝(留下临时目录胜过 dispose 失败),并且是幂等的。
- **固定**(设置 `pinnedPath`):原样返回该路径,绝不创建、绝不移除。通过固定目录在运行间共享子进程状态的部署负责该目录的生命周期。
## 测试
`tests/subagent-subprocess.spec.ts`:环境变量清理和配置目录辅助函数使用真实进程环境与真实文件系统运行(rm 失败路径在 fs 边界注入拒绝,因为真实递归 rm 失败无法跨平台稳定触发,而且 root 会忽略权限位);退出等待和平台终止路径使用可脚本化的假子进程。[ACP 后端测试套件](../subagent-acp/README.md)会针对真实子进程端到端执行这些机制。
## 模型体验
通过基于进程的 subagent 后端间接产生影响;这些后端的子进程组合受凭据清理和隔离配置目录约束。
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与延期工作
- **凭据清理基于名称**:只移除匹配 `KEY` / `SECRET` / `TOKEN` 的变量;除非后端提供更严格的环境,否则 `PASSWORD` 等名称不同的秘密仍会传入。
- **信号只针对直接子进程**:清理依赖可协作的 CLI 在退出前回收其后代;重新托管或独立脱离的孙进程可能比该阶梯存活更久。
- **全新配置目录的清理是尽力而为**:`rm` 失败时会在 OS 临时根目录下留下私有状态,而不会使 dispose 失败。
- **固定配置目录完全由操作方负责**:辅助函数既不创建、校验、锁定,也不移除这些目录,因此并发运行可能共享该状态并发生竞态。
@@ -1,37 +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",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -1,223 +0,0 @@
/**
* Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external
* agent as a child process and must keep the parent deployment's credentials out of it, tear
* it down to quiescence, and isolate it from the host user's on-disk CLI state. This package
* registers no provider; consuming plugins own and validate every timing or path default.
* @module @deepseek-ai/dsh-subagent-subprocess
*/
import type { ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Credential-shaped ambient env vars are NOT forwarded to a child by default
* (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
* spawned process implicitly). Same pattern as the bash executor. The child
* agent needs its OWN credentials to reach a model — those are supplied
* explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER
* the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
* `AWS_SECRET_ACCESS_KEY` does not.
*/
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* The ambient env minus credential-shaped vars, plus the caller's explicit
* env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so
* a child CLI runs normally; only credential-shaped names are dropped.
* @param extra - explicit vars layered on top AFTER the scrub, so a
* credential-shaped name supplied deliberately still reaches the child.
* @returns the environment to spawn the child with.
*/
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...extra }
}
/**
* Capture the child's spawn-level `error` event as a promise. Call in the same tick as
* `spawn()`; otherwise an early event can be unhandled and crash the parent.
* @param child - the just-spawned child process.
* @returns a promise that RESOLVES (never rejects) with the child's first
* `error` event; for a child that spawns cleanly it never settles.
*/
export function spawnFailure(child: ChildProcess): Promise<Error> {
return new Promise<Error>((resolve) => {
child.once('error', (err) => { resolve(err) })
})
}
/**
* Race the child's exit against a timer. Neither outcome leaves anything
* behind on the child: the exit listener is removed on timeout and the timer
* is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll
* loop) never accumulate listeners.
* @param child - the child process to watch.
* @param ms - the wait window in milliseconds.
* @returns `true` if the child exits within `ms` (immediately if it is
* already gone), `false` on timeout.
*/
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const onExit = (): void => {
clearTimeout(timer)
resolve(true)
}
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
const timer = setTimeout(() => {
child.removeListener('exit', onExit)
resolve(false)
}, ms).unref()
child.once('exit', onExit)
})
}
/**
* The two grace periods of the dispose ladder, supplied per call by the
* consuming backend — each plugin carries them as defaulted, validated
* `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is
* deployment-tunable and this library hardcodes nothing.
*/
export interface DisposeLadderGraces {
/**
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
* ON ITS OWN — flush durable state, tear down its own nested subprocesses —
* before the parent escalates to platform termination. A separate (usually WIDER)
* grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
* child's EOF-driven teardown may itself be waiting on a signal-trapping
* grandchild plus a final flush, needing more than one signal-grace of
* headroom.
*/
disposeEofGraceMs: number
/**
* Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
* `SIGKILL`; Windows applies it after the direct forced termination.
*/
disposeGraceMs: number
}
/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
let accepted = false
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.off('exit', onExit)
child.off('error', onError)
}
const settle = (complete: () => void): void => {
if (settled) return
settled = true
cleanup()
complete()
}
const onExit = (): void => { settle(resolve) }
const onError = (error: Error): void => { settle(() => { reject(error) }) }
child.once('exit', onExit)
child.once('error', onError)
const timer = setTimeout(() => {
const disposition = accepted ? 'accepted' : 'refused'
settle(() => {
reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
})
}, ms).unref()
try {
accepted = child.kill('SIGKILL')
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
} catch (error: unknown) {
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
}
})
}
/**
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
* cooperative flush, then use the host's graceful and forced termination semantics. POSIX
* sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node
* maps both signals to `TerminateProcess`.
*
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
* @param platform - the host platform, injectable for unit coverage.
* @throws When forced termination errors or the child does not report exit within
* `disposeGraceMs`.
*/
export async function disposeChildProcess(
child: ChildProcess,
graces: DisposeLadderGraces,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
// Already gone: nothing to reap.
if (child.exitCode !== null || child.signalCode !== null) return
// 1. Close stdin and allow cooperative teardown and durable-state flush.
child.stdin?.end()
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
if (platform !== 'win32') {
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
}
// 3. Force-kill and await a bounded exit edge.
await forceTerminateWithin(child, graces.disposeGraceMs)
}
/**
* A per-run config directory handle for an external CLI child — the target of
* `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to
* the child's environment; call {@link remove} on dispose.
*/
export interface IsolatedConfigDir {
/** The directory to point the child at. */
path: string
/**
* Best-effort cleanup: removes the directory (recursively) iff this handle
* CREATED it — a pinned directory is never removed. Idempotent; never
* rejects (a leftover dir under the OS temp root is preferable to a failed
* dispose).
*/
remove(): Promise<void>
}
/**
* An isolated config dir for one child run, independent of host CLI state. Without
* `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory
* is returned unchanged and remains deployment-owned.
*
* @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
* `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
* @param pinnedPath - a deployment-pinned directory to use instead of a
* fresh one.
* @returns the directory handle: `path` for the child env, `remove()` for
* dispose.
*/
export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise<IsolatedConfigDir> {
if (pinnedPath !== undefined) {
return {
path: pinnedPath,
remove(): Promise<void> {
// A pinned dir is deployment-owned state (config the user asked to
// persist across runs); removing it here would destroy it. No-op.
return Promise.resolve()
},
}
}
const path = await mkdtemp(join(tmpdir(), prefix))
return {
path,
async remove(): Promise<void> {
try {
await rm(path, { recursive: true, force: true })
} catch {
// Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead
// child left an unreadable entry behind).
}
},
}
}
@@ -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-subsubprocess-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 */
@@ -1,389 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcess } from 'node:child_process'
import {
buildChildEnv,
createIsolatedConfigDir,
disposeChildProcess,
spawnFailure,
} from '../src/index.ts'
// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, rm: vi.fn(actual.rm) }
})
/**
* Unit tests for the shared out-of-process machinery. The env scrub and the
* isolated-config-dir helpers run against the REAL process env and REAL
* filesystem (one exception: the rm-failure path injects its rejection at the
* mocked fs boundary, see above); the exit waits and the dispose ladder run
* against a scriptable fake child so each escalation tier's timing is driven
* deterministically (the ACP backend's suite exercises the same ladder
* against real subprocesses end to end).
*/
/** What fells a scripted {@link FakeChild}. */
type LethalTrigger = 'eof' | NodeJS.Signals
/** Per-scenario script for a {@link FakeChild}. */
interface FakeChildScript {
/**
* The one trigger that makes the child exit (SIGKILL always does,
* uncatchable, like a real process). Omitted: only SIGKILL fells it.
*/
diesOn?: LethalTrigger
/** Delay (ms) between the lethal trigger and the exit event. */
delayMs?: number
/** Complete the scripted exit inside the triggering call. */
synchronousExit?: boolean
/** `false` models a child spawned without a stdin pipe. */
stdin?: boolean
}
/**
* A scriptable stand-in for a ChildProcess carrying exactly the surface the
* helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
* `exit` event.
*/
class FakeChild extends EventEmitter {
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
readonly kills: NodeJS.Signals[] = []
stdinEnded = false
readonly stdin: { end: () => void } | null
constructor(private readonly script: FakeChildScript = {}) {
super()
this.stdin = script.stdin === false
? null
: { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
}
kill(signal: NodeJS.Signals): boolean {
this.kills.push(signal)
this.maybeDie(signal)
return true
}
private maybeDie(trigger: LethalTrigger): void {
// SIGKILL is uncatchable — it always fells the child; any other trigger
// only when the scenario scripts it as the lethal one.
if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
const exit = (): void => {
if (trigger === 'eof') this.exitCode = 0
else this.signalCode = trigger
this.emit('exit', this.exitCode, this.signalCode)
}
if (this.script.synchronousExit === true) exit()
else setTimeout(exit, this.script.delayMs ?? 0)
}
}
/** The helpers take a real ChildProcess; the fake carries the read surface. */
function asChild(fake: FakeChild): ChildProcess {
return fake as unknown as ChildProcess
}
describe('buildChildEnv', () => {
it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
process.env.DSH_PROC_TEST_API_KEY = 'leak'
process.env.dsh_proc_test_secret = 'leak'
process.env.DSH_PROC_TEST_TOKEN = 'leak'
try {
const env = buildChildEnv({})
expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
expect(env.dsh_proc_test_secret).toBeUndefined()
expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
} finally {
delete process.env.DSH_PROC_TEST_API_KEY
delete process.env.dsh_proc_test_secret
delete process.env.DSH_PROC_TEST_TOKEN
}
})
it('forwards normal ambient vars', () => {
expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
})
it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
try {
const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
// The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
} finally {
delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
}
})
it('an extra overrides the ambient value of a non-credential var', () => {
process.env.DSH_PROC_TEST_PLAIN = 'ambient'
try {
expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
} finally {
delete process.env.DSH_PROC_TEST_PLAIN
}
})
})
describe('spawnFailure', () => {
it('resolves (never rejects) with the first error event', async () => {
const fake = new FakeChild()
const failure = spawnFailure(asChild(fake))
const err = new Error('spawn ENOENT')
fake.emit('error', err)
await expect(failure).resolves.toBe(err)
})
it('never settles for a child that spawns cleanly and exits', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM' })
const failure = spawnFailure(asChild(fake))
fake.kill('SIGTERM')
await new Promise<void>(resolve => fake.once('exit', () => { resolve() }))
// A clean lifecycle emits `exit`, never `error` — the capture stays
// pending forever, so a race against it is decided by the other arms.
const settled = await Promise.race([
failure.then(() => 'settled'),
new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
])
expect(settled).toBe('pending')
})
})
describe('disposeChildProcess', () => {
it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
const fake = new FakeChild()
fake.exitCode = 0
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('returns immediately for a child already dead by signal', async () => {
const fake = new FakeChild()
fake.signalCode = 'SIGKILL'
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual([])
expect(fake.exitCode).toBe(0)
})
it('recognizes a child that exits synchronously on stdin EOF', async () => {
const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.exitCode).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('recognizes a child that exits synchronously on SIGTERM', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
// Quiescence, not a request: at resolution the child has ACTUALLY exited
// (the exit event landed, despite the scripted post-SIGKILL delay).
expect(fake.signalCode).toBe('SIGKILL')
})
it('recognizes a child already gone when the final exit wait begins', async () => {
const fake = new FakeChild({ synchronousExit: true })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
queueMicrotask(() => {
if (marker === 'exitCode') fake.exitCode = 0
else fake.signalCode = 'SIGTERM'
})
return true
})
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('walks the ladder for a child spawned without a stdin pipe', async () => {
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it('propagates a forced-termination error without waiting for the grace', async () => {
const fake = new FakeChild()
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
fake.emit('error', failure)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toBe(failure)
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
const fake = new FakeChild()
const failure = new Error('invalid signal state')
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds a refused forced termination that produces no error or exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return false
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds an accepted forced termination that never reports exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return true
})
await expect(disposeChildProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
})
describe('createIsolatedConfigDir', () => {
it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
try {
expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
const st = await stat(dir.path)
expect(st.isDirectory()).toBe(true)
// Windows reports synthetic POSIX mode bits; privacy comes from the
// inherited directory ACL rather than chmod-compatible mode bits.
if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
} finally {
await dir.remove()
}
})
it('creates a distinct dir per call (per-run isolation)', async () => {
const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
try {
expect(a.path).not.toBe(b.path)
} finally {
await a.remove()
await b.remove()
}
})
it('remove() deletes a fresh dir recursively and is idempotent', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
await writeFile(join(dir.path, 'settings.json'), '{}')
await dir.remove()
expect(existsSync(dir.path)).toBe(false)
// Second remove: nothing left to delete, still resolves.
await expect(dir.remove()).resolves.toBeUndefined()
})
it('returns a pinned dir verbatim and NEVER removes it', async () => {
const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
try {
const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
expect(dir.path).toBe(pinned)
await dir.remove()
// The deployment owns a pinned dir's lifecycle — remove() must not touch it.
expect(existsSync(pinned)).toBe(true)
} finally {
await rm(pinned, { recursive: true, force: true })
}
})
it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
expect(dir.path).toBe(missing)
expect(existsSync(missing)).toBe(false)
await dir.remove()
expect(existsSync(missing)).toBe(false)
})
it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
try {
// The swallow contract is error-kind agnostic; EACCES stands in for the
// family (EBUSY, a vanished mount, …) that best-effort must absorb.
vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
await expect(dir.remove()).resolves.toBeUndefined()
// The injected rejection consumed the only rm call — nothing was deleted.
expect(existsSync(dir.path)).toBe(true)
} finally {
await rm(dir.path, { recursive: true, force: true })
}
})
})
@@ -1,15 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: f91609dc6b6e27fc26e5ffb4b7fd68fc9f4de556
README.zh.md: d4e2cd69d21772834e0e55db2b14e19ea7ce1832
README.md: 657855aff67230ee22b8137ae3aabc76aff8f860
README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b
+3 -3
View File
@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.
+3 -3
View File
@@ -2,11 +2,11 @@
[English](README.md) | 中文
spawn 受管子进程的共用归属位置:完全显式的 spawn spec、附带 spill 文件的有界尾部保留输出、经凭据清除的环境、基于偏移量的增量读取,以及 SIGTERM→宽限期→SIGKILL 的进程组终止。命令默认值补全、shell 语义、deadline 与呈现留在消费方:[bash 执行器家族](../bash/README.md)是第一个消费方,也拥有上述各项。参见[进程管理器 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
spawn 受管子进程的共用归属位置:完全显式的 spawn spec,其 stdio 处置方式(disposition)为 Node 形状、按流划分(原始管道、inherit、附带 spill 文件的有界尾部保留收集);harness 中所有 spawn 调用方共用的那一份凭据清除;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、deadline、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACPAgent Client Protocolsubagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
| 包(package | ctx 键 | 角色 |
|---|---|---|
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式的 `SubprocessSpawnSpec`携带基于偏移量读取器的 `SubprocessHandle`,以及共享的 `DSH_*` 受管环境与 `CollectedOutput` 词汇 |
| [`subprocess-local`](subprocess-local/README.md)`@deepseek-ai/dsh-subprocess-local` | 无 | 本地实现:detached 进程、附带有界私有 spill 文件的尾部保留截断、凭据清除与 `DSH_*` 合并次序、kill 升级,以及先终止再等待退出的 dispose(资源释放) |
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式`SubprocessSpawnSpec``SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 |
| [`subprocess-local`](subprocess-local/README.md)`@deepseek-ai/dsh-subprocess-local` | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose |
服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。
@@ -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: a18772055d33369f6feec1b1a0751bb299303e04
README.zh.md: bc829d48e846055853753522287967cc9fd42ca6
README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623
README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069
@@ -2,15 +2,15 @@
English | [中文](README.zh.md)
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)).
## Behavior (and where it came from)
- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience
@@ -22,7 +22,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
@@ -2,15 +2,15 @@
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程 spawn收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。该实现没有任何配置:每项限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里(目前是 [`dsh-bash-local`](../../bash/bash-local/README.md))。
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程 spawn依照 spec 中按流划分的 stdio 处置方式(disposition)完成接线(原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围、按 SIGTERM→SIGKILL 升级发送信号。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里([`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。
## 行为(以及设计来源)
- **带升级的 detached 进程**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。组长进程退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止这次 spawn 结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **尾部保留截断 + 有界 spill 文件**:输出超过某条流的上限后内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到一个私有临时文件,并在可用时报告该路径。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
- **带平台正确信号发送的 detached 进程**POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**`SubprocessHandle` 的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存。
- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能终止每个仍在运行的进程并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
- **基于偏移量的读取**收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
## 模型体验
@@ -22,7 +22,7 @@
## 已知限制与暂缓事项
- **仅支持 POSIX**detached 进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
@@ -29,11 +29,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -1,38 +1,41 @@
/**
* Local-subprocess implementation of the subprocess seam. Each spawn is
* a detached process group with bounded, spill-backed output; disposal kills
* and joins live groups. It has no config: every limit arrives on the spec,
* so the deployment-varying choices stay with the calling seam's config (the
* bash executor's, today).
* Local implementation of the subprocess seam. Each spawn is a detached
* process tree with the spec's per-stream stdio dispositions; disposal
* terminates and joins live trees. It has no config: every disposition and
* limit arrives on the spec, so the deployment-varying choices stay with the
* calling seam's config (the bash executor's, the LSP host's, …).
* @module @deepseek-ai/dsh-subprocess-local
*/
import { Context } from 'cordis'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { spawnProcess } from './spawn.ts'
import { spawnSubprocess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
/**
* Local subprocess service: detached process groups, tail-keep truncation with
* bounded spill files, credential-scrubbed environment, and group
* SIGTERM→grace→SIGKILL escalation.
* Local subprocess service: detached process trees, Node-shaped stdio
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
* files), credential-scrubbed environment, tree-scoped signalling with
* SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder.
*/
export class LocalSubprocessService extends SubprocessService {
/** Live handles retained only so disposal can kill and join them. */
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Test seam: spill knobs forwarded to spawnProcess. */
/** Test seam: spill and platform knobs forwarded to spawnSubprocess. */
internals: SpawnInternals = {}
constructor(ctx: Context) {
super(ctx)
ctx.effect(() => async () => {
// Await closure so even a TERM-trapping child cannot outlive the fiber.
// Terminate (escalating), then await WHOLE-TREE exit — not just the
// direct child's settlement — so even a TERM-trapping descendant cannot
// outlive the fiber.
const pending: Promise<unknown>[] = []
for (const handle of this.live) {
handle.kill()
handle.terminate()
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}))
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
}
this.live.clear()
await Promise.all(pending)
@@ -40,12 +43,15 @@ export class LocalSubprocessService extends SubprocessService {
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const handle = spawnProcess(spec, this.internals)
const handle = spawnSubprocess(spec, this.internals)
this.live.add(handle)
handle.done.then(
() => { this.live.delete(handle) },
() => { this.live.delete(handle) },
)
// Release ownership only once the whole TREE is gone, not at direct-child
// settlement — a TERM-trapping helper that outlives the leader must stay
// owned so teardown can still escalate it. For the common no-survivor
// case waitForExit resolves immediately after settlement.
const release = (): Promise<void> =>
handle.waitForExit().then(() => { this.live.delete(handle) })
handle.done.then(release, release)
return handle
}
}
+298 -101
View File
@@ -1,33 +1,37 @@
/**
* Process plumbing for the local subprocess service: detached process-group
* spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation.
* Process plumbing for the local subprocess service: detached process-tree
* spawn with per-stream stdio dispositions, tail-keep collection with spill
* files, tree-scoped signalling (POSIX groups; Windows taskkill), the
* SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder.
* This layer reacts to an abort signal; callers own deadlines and classify
* causes.
* @module dsh-subprocess-local/spawn
*/
import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
import type { Readable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { setTimeout as sleepMs } from 'node:timers/promises'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type {
CollectedOutput,
DshEnvironment,
SubprocessCollect,
SubprocessDisposeGraces,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
/**
* Credential-shaped env vars are NOT forwarded to children (the harness's
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
* spill files). Same default pattern as Codex's env policy; a future config
* can whitelist specific vars when a workflow genuinely needs one.
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* Build a child environment from scrubbed ambient values, ordinary caller
* entries, and a managed `DSH_*` snapshot. Ambient managed names are removed;
* ordinary and managed entries reject the other channel's namespace before
* `dshEnv` merges last.
* Build a child environment from the scrubbed parent base, ordinary caller
* entries, and a managed `DSH_*` snapshot. Ordinary and managed entries
* reject the other channel's namespace before `dshEnv` merges last.
* @param extra - caller entries; `DSH_*` names are rejected.
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
@@ -36,10 +40,6 @@ export function childEnv(
extra?: Readonly<Record<string, string>>,
dshEnv?: DshEnvironment,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
}
for (const key of Object.keys(extra ?? {})) {
if (key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`)
@@ -50,13 +50,30 @@ export function childEnv(
throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`)
}
}
return { ...env, ...extra, ...dshEnv }
return { ...scrubbedParentEnv(), ...extra, ...dshEnv }
}
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */
export interface SpawnInternals {
/** Directory for spill files (defaults to the OS temp dir). */
spillDir?: string
/** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
taskkill?: (pid: number) => void
/** Host platform override for signalling decisions. */
platform?: NodeJS.Platform
}
/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */
const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER'
/**
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
* awaited teardown must keep the event loop alive until the tree really
* exits, or the parent can exit while claiming quiescence and orphan the
* survivors it promised to reap.
*/
function sleepTick(): Promise<void> {
return sleepMs(15)
}
let spillCounter = 0
@@ -73,9 +90,11 @@ function privateSpillDir(): string {
}
/**
* Collects one stream with a bounded in-memory tail. On first overflow a
* spill file is created and every chunk (including those already collected)
* is appended there while the full stream remains within `maxSpillBytes`.
* Collects one stream with a bounded in-memory tail. With a spill cap, on
* first overflow a spill file is created and every chunk (including those
* already collected) is appended there while the full stream remains within
* the cap; without one, only the in-memory tail is ever retained (the
* diagnostic-tail shape — a language server's stderr).
*
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
* end of command output; the spill file covers the head.
@@ -86,23 +105,25 @@ export class OutputCollector {
private dropped = false
private spillFd: number | undefined
private spillFile: string | undefined
private spillDisabled = false
private spillDisabled: boolean
/** Total bytes ever pushed (not just retained). */
private total = 0
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number,
private readonly maxSpillBytes: number | undefined,
private readonly label: string,
private readonly spillDir: string,
) {}
) {
this.spillDisabled = maxSpillBytes === undefined
}
/**
* Ingest one stream chunk, counting it toward the whole-stream total. On
* first overflow of the in-memory cap a spill file is opened and every chunk
* (already-collected ones included) is appended there from then on; the
* in-memory tail then drops whole chunks from its head (or the head of a
* single over-cap chunk) until it fits the cap again.
* first overflow of the in-memory cap a spill file is opened (when spilling
* is enabled) and every chunk (already-collected ones included) is appended
* there from then on; the in-memory tail then drops whole chunks from its
* head (or the head of a single over-cap chunk) until it fits the cap again.
* @param chunk - the raw bytes from one stream 'data' event.
*/
push(chunk: Buffer): void {
@@ -111,26 +132,27 @@ export class OutputCollector {
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
// Drop whole chunks from the head; pipe chunks are small (≤64KiB), so
// the retained tail tracks the cap closely enough for a model-facing
// truncation boundary. (length > 1 was just checked — shift() returns.)
const head = this.chunks.shift() as Buffer
this.bytes -= head.length
this.dropped = true
}
if (this.bytes > this.maxBytes && this.chunks.length === 1) {
// A single chunk larger than the cap: keep its tail.
const only = this.chunks[0] as Buffer
this.chunks[0] = only.subarray(only.length - this.maxBytes)
this.bytes = this.maxBytes
while (this.bytes > this.maxBytes) {
const head = this.chunks[0] as Buffer
const excess = this.bytes - this.maxBytes
if (head.length <= excess) {
// Drop the whole head chunk (length ≥ 1 is guaranteed while over cap).
this.chunks.shift()
this.bytes -= head.length
} else {
// Trim the head so the retained window is byte-exact at the cap — a
// diagnostic tail (an LSP server's stderr) must hold the LAST
// maxBytes regardless of how the stream was chunked.
this.chunks[0] = head.subarray(excess)
this.bytes -= excess
}
this.dropped = true
}
}
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
private spillAll(chunk: Buffer): void {
if (this.total > this.maxSpillBytes) {
if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
@@ -194,22 +216,30 @@ export class OutputCollector {
}
/**
* Close the spill file (if any) and return the final output. A failed close
* (delayed writeback fault) stops advertising the spill path — the file may
* be missing its tail — but still returns the in-memory result.
* Close the spill file once the stream has ended. A failed close (delayed
* writeback fault) stops advertising the spill path — the file may be
* missing its tail — while every in-memory read keeps working. Idempotent;
* the spawn path seals both collectors at settlement so reads after exit
* never point at a still-open file.
*/
seal(): void {
if (this.spillFd === undefined) return
try {
closeSync(this.spillFd)
} catch {
// A delayed writeback failure makes the spill unreliable; keep the
// in-memory result but stop advertising that file.
this.spillFile = undefined
}
this.spillFd = undefined
}
/**
* Seal the spill file and return the final output.
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
*/
finalize(): CollectedOutput {
if (this.spillFd !== undefined) {
try {
closeSync(this.spillFd)
} catch {
// A delayed writeback failure makes the spill unreliable; keep finalize
// total but stop advertising that file.
this.spillFile = undefined
}
this.spillFd = undefined
}
this.seal()
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
@@ -219,9 +249,9 @@ export class OutputCollector {
}
/**
* Send `sig` to a detached process group. Never throws: delivery races process
* exit and may run in a timer callback, so failures are contained and a
* non-positive pid is a no-op.
* Send `sig` to a detached POSIX process group. Never throws: delivery races
* process exit and may run in a timer callback, so failures are contained and
* a non-positive pid is a no-op.
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
* @param sig - the signal to deliver to the whole group.
*/
@@ -235,14 +265,65 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
}
/**
* Spawn one isolated detached process group and collect its output.
* Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject.
* @param spec - fully resolved argv, cwd, limits, and cancellation.
* @param internals - test-only spill-directory override.
* @returns live process handle and outcome promise.
* Terminate one Windows process tree with `taskkill /T /F`. Contained like
* POSIX group signalling — delivery races tree exit, so an absent tree, a
* nonzero status, or a missing taskkill binary must not break idempotent
* teardown.
* @param pid - root process id; non-positive is a no-op.
*/
export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
export function taskkillProcessTree(pid: number): void {
if (pid <= 0) return
// Outcome deliberately unchecked: an already-absent tree (status 128), exit
// races, and a missing taskkill binary (spawnSync reports, never throws) are
// as tolerable here as ESRCH is for a POSIX group signal.
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
}
/**
* Signal a detached process tree with platform-correct semantics: POSIX
* signals the negative process-group id and falls back to the direct child
* when the group is gone; Windows terminates the tree via taskkill (any
* signal value force-terminates — Node maps signals to TerminateProcess).
*/
function signalTree(
platform: NodeJS.Platform,
pid: number,
sig: NodeJS.Signals,
child: ChildProcess,
taskkill: (pid: number) => void,
): void {
if (platform === 'win32') {
taskkill(pid)
return
}
/* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
if (pid <= 0) return
try {
process.kill(-pid, sig)
} catch {
/* v8 ignore start -- the fallback needs a live child whose group signal fails
(EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
try {
child.kill(sig)
} catch {
// The direct child already exited; teardown remains idempotent.
}
/* v8 ignore stop */
}
}
/**
* Spawn one isolated detached process tree with the spec's per-stream stdio
* dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome};
* only spawn failures reject.
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
* @param internals - test-only spill-directory, platform, and taskkill overrides.
* @returns live subprocess handle.
*/
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
const spillDir = internals.spillDir ?? privateSpillDir()
const platform = internals.platform ?? process.platform
const taskkill = internals.taskkill ?? taskkillProcessTree
if (spec.signal?.aborted) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
@@ -252,41 +333,97 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
}
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env, spec.dshEnv)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
mode !== 'pipe' && mode !== 'inherit'
const outMode = spec.stdio.stdout
const errMode = spec.stdio.stderr
const stdinMode = spec.stdio.stdin
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
const env = childEnv(spec.env, spec.dshEnv)
const child = spawn(program, args, {
cwd: spec.cwd,
env,
stdio: [
stdinMode === 'ignore' ? 'ignore' : 'pipe',
outMode === 'inherit' ? 'inherit' : 'pipe',
errMode === 'inherit' ? 'inherit' : 'pipe',
],
// `detached` gives teardown a tree root on POSIX (its own process group);
// Windows terminates by root pid through taskkill /T instead.
detached: platform !== 'win32',
})
const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {
if (!isCollect(mode) || stream === null) return undefined
const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir)
stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
return collector
}
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: NodeJS.Timeout | undefined
let settled = false
// Failed spawns use pid -1 so kill remains a no-op.
// Failed spawns use pid -1 so signalling remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
/** Whether the detached tree's root (or POSIX group) is still alive. */
const treeAlive = (): boolean => {
if (pid <= 0) return false
if (platform === 'win32') {
// Windows has no group-liveness probe; the direct child's exit is the
// observable boundary (taskkill /T already took the tree with it).
return child.exitCode === null && child.signalCode === null
}
try {
process.kill(-pid, 0)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
/* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing
makes observing the other arm platform-dependent. */
if (code === 'ESRCH') return false
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
if (code === 'EPERM') return true
return child.exitCode === null && child.signalCode === null
/* v8 ignore stop */
}
}
const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => {
// Guard on TREE liveness, not outcome settlement: a TERM-trapping helper
// can outlive the settled direct child and must stay signalable, while a
// fully-dead tree (possible pid reuse) must not be re-signalled from a
// caller's finally block.
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
// After settlement the group is gone and the pid may be reused; callers
// commonly kill() in a finally, so this must not re-signal or start a
// timer that outlives the handle.
if (settled) return
killGroup(pid, 'SIGTERM')
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
if (!treeAlive()) return
signalTree(platform, pid, 'SIGTERM', child, taskkill)
// The escalation must survive direct-child settlement — the leader dying
// does not mean the tree died — so settle does not clear this timer, and
// it re-probes tree liveness before force-killing. It stays ref'd: the
// pending SIGKILL is a commitment, and a parent exiting before it fires
// would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = setTimeout(() => {
if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill)
}, spec.graceMs)
}
// The caller owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
const onAbort = (): void => { terminate() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Stdin writes are best-effort; process exit and captured output remain authoritative.
if (child.stdin !== null) {
// Batch stdin is written and closed up front; process exit and captured
// output remain authoritative, so write errors (EPIPE) are best-effort.
if (typeof stdinMode === 'object' && child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
child.stdin.end(stdinMode.data)
}
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
@@ -294,15 +431,14 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
child.stdout.destroy()
child.stderr.destroy()
// Only harness-collected pipes are force-closed at the drain boundary;
// a 'pipe'-mode stream belongs to the caller and closes with the child.
if (stdoutCollector !== undefined) child.stdout?.destroy()
if (stderrCollector !== undefined) child.stderr?.destroy()
stdoutCollector?.seal()
stderrCollector?.seal()
cleanup()
resolve({
exitCode,
signal,
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
resolve({ exitCode, signal })
}
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
@@ -311,15 +447,76 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal
reject(error)
})
child.on('exit', (exitCode, signal) => {
// A surviving descendant that inherited a pipe must not hold the
// outcome open indefinitely: after exit, the same bounded grace that
// governs kills also bounds the close wait.
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
if (graceTimer !== undefined) clearTimeout(graceTimer)
// graceTimer deliberately NOT cleared: the SIGKILL escalation must be
// able to reach tree survivors after the direct child settles.
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})
return { pid, stdout, stderr, done, kill }
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
while (treeAlive()) {
if (signal?.aborted) return false
await sleepTick()
}
return true
}
/**
* Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test.
* Tree liveness, not direct-child settlement: a TERM-trapping helper that
* outlives the leader must hold the ladder on its tier until it exits.
*/
const treeExitsWithin = async (ms: number): Promise<boolean> => {
using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT)
return await waitForExit(bound.signal)
}
let disposal: Promise<void> | undefined
const dispose = (graces: SubprocessDisposeGraces): Promise<void> => (disposal ??= (async () => {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (pid <= 0) {
await done.catch(() => {})
return
}
// 1. Close a piped stdin and allow cooperative teardown and flush.
if (stdinMode === 'pipe') child.stdin?.end()
if (await treeExitsWithin(graces.eofGraceMs)) return
// 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates.
if (platform !== 'win32') {
kill('SIGTERM')
if (await treeExitsWithin(graces.graceMs)) return
}
// 3. Force-kill the tree and await a bounded exit edge.
kill('SIGKILL')
if (!(await treeExitsWithin(graces.graceMs))) {
throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`)
}
})())
return {
pid,
/* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined,
stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined,
stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined,
/* v8 ignore stop */
collected: {
...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {},
...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
},
done,
kill,
terminate,
waitForExit,
dispose,
}
}
@@ -7,9 +7,11 @@ function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): Su
return {
argv: ['bash', '-c', command],
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
maxSpillBytes: 64 * 1024 * 1024,
stdio: {
stdin: 'ignore',
stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
},
graceMs: 200,
...overrides,
}
@@ -19,9 +21,10 @@ describe('LocalSubprocessService', () => {
it('registers as ctx.subprocess and spawns managed handles', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const result = await ctx.subprocess.spawn(spec('echo managed')).done
const handle = ctx.subprocess.spawn(spec('echo managed'))
const result = await handle.done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('managed\n')
expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
await fiber.dispose()
})
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess'
import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
@@ -33,15 +33,25 @@ vi.mock('node:fs', async (importOriginal) => {
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
stdoutMaxBytes?: number
stderrMaxBytes?: number
maxSpillBytes?: number
stdin?: string
}
function spec(command: string, overrides: SpecOverrides = {}) {
const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides
return {
argv: ['bash', '-c', command],
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
maxSpillBytes: 64 * 1024 * 1024,
stdio: {
stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const,
stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } },
stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } },
},
graceMs: 3_000,
...overrides,
...rest,
}
}
@@ -62,12 +72,22 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.readFrom(0).text.includes(expected)) return
if (running.collected.stdout!.readFrom(0).text.includes(expected)) return
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
}
/** Await settlement and project both collected streams like a batch outcome. */
async function finish(running: SubprocessHandle) {
const outcome = await running.done
const final = (reader: SubprocessOutputReader | undefined) => {
const read = reader!.readFrom(0)
return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} }
}
return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) }
}
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
@@ -82,9 +102,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('spawnProcess', () => {
describe('spawnSubprocess', () => {
it('captures stdout on success', async () => {
const result = await spawnProcess(spec('echo hello')).done
const result = await finish(spawnSubprocess(spec('echo hello')))
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.stdout.text).toBe('hello\n')
@@ -93,33 +113,33 @@ describe('spawnProcess', () => {
})
it('captures stderr separately', async () => {
const result = await spawnProcess(spec('echo oops >&2')).done
const result = await finish(spawnSubprocess(spec('echo oops >&2')))
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
expect(result.stderr.text).toBe('oops\n')
})
it('captures both streams', async () => {
const result = await spawnProcess(spec('echo out; echo err >&2')).done
const result = await finish(spawnSubprocess(spec('echo out; echo err >&2')))
expect(result.stdout.text).toBe('out\n')
expect(result.stderr.text).toBe('err\n')
})
it('reports non-zero exit codes', async () => {
const result = await spawnProcess(spec('exit 42')).done
const result = await finish(spawnSubprocess(spec('exit 42')))
expect(result.exitCode).toBe(42)
expect(result.signal).toBeNull()
})
it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
const result = await spawnProcess(spec('echo "${TERM:-unset}"', {
const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', {
env: { TERM: 'callers-choice' },
})).done
})))
expect(result.stdout.text).toBe('callers-choice\n')
})
it('runs in the requested cwd', async () => {
const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done
const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' })))
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
@@ -129,7 +149,7 @@ describe('spawnProcess', () => {
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
@@ -137,10 +157,21 @@ describe('spawnProcess', () => {
expect(result.exitCode).toBeNull()
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
running.terminate()
const result = await running.done
expect(result.signal).toBe('SIGKILL')
})
it('kill() sends one signal Node-style, without escalation', async () => {
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 }))
await waitForStdout(running, 'armed\n')
running.kill() // trapped SIGTERM, no SIGKILL follow-up
await new Promise(resolve => setTimeout(resolve, 400))
expect(running.collected.stdout).toBeDefined()
running.kill('SIGKILL') // explicit signal choice, still no timers
const result = await running.done
expect(result.signal).toBe('SIGKILL')
})
@@ -149,7 +180,7 @@ describe('spawnProcess', () => {
// The subshell writes the sleep's pid then waits on it; killing the
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
@@ -161,7 +192,7 @@ describe('spawnProcess', () => {
it('aborts via AbortSignal mid-run', async () => {
const controller = new AbortController()
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
@@ -170,19 +201,19 @@ describe('spawnProcess', () => {
it('throws when the signal is already aborted before spawn', () => {
const controller = new AbortController()
controller.abort('too late')
expect(() => spawnProcess(spec('echo hi', { signal: controller.signal })))
expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal })))
.toThrow(/aborted before spawn: too late/)
})
it('rejects with a spawn error for a nonexistent cwd', async () => {
await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
.rejects.toThrow(/ENOENT/)
})
it('kill() is idempotent (second call does not restart escalation)', async () => {
const running = spawnProcess(spec('sleep 60'))
running.kill()
running.kill()
it('terminate() is idempotent (second call does not restart escalation)', async () => {
const running = spawnSubprocess(spec('sleep 60'))
running.terminate()
running.terminate()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
@@ -190,10 +221,10 @@ describe('spawnProcess', () => {
it('bounds inherited-pipe draining after the shell exits', async () => {
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
const started = Date.now()
const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const descendant = await waitForPidFile(pidFile)
try {
const result = await running.done
const result = await finish(running)
expect(Date.now() - started).toBeLessThan(1_000)
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('shell-done\n')
@@ -206,7 +237,7 @@ describe('spawnProcess', () => {
describe('stdin and extra env (set by in-process plugins)', () => {
it('writes stdin to the command and closes it', async () => {
const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done
const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' })))
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hello from stdin\n')
})
@@ -214,7 +245,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
it('a command that reads stdin sees EOF when none is supplied', async () => {
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
// output (it does NOT block).
const result = await spawnProcess(spec('cat')).done
const result = await finish(spawnSubprocess(spec('cat')))
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
})
@@ -222,25 +253,25 @@ describe('stdin and extra env (set by in-process plugins)', () => {
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done
const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other')))
expect(none.stdout.text).toBe('char\n')
const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })))
expect(piped.stdout.text).toBe('socket\n')
})
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
})).done
})))
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the credential scrub', async () => {
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
})).done
})))
expect(result.stdout.text).toBe('explicit-wins\n')
})
@@ -248,20 +279,20 @@ describe('stdin and extra env (set by in-process plugins)', () => {
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
// The handler swallows that write error and `done` reports the child's real exit.
const big = 'x'.repeat(1024 * 1024)
const result = await spawnProcess(spec('exit 7', { stdin: big })).done
const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big })))
expect(result.exitCode).toBe(7)
})
})
describe('output truncation and spill', () => {
it('applies stdout and stderr caps independently', async () => {
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
stdoutMaxBytes: 500,
stderrMaxBytes: 100,
}),
{ spillDir },
).done
))
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
@@ -270,10 +301,10 @@ describe('output truncation and spill', () => {
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
))
expect(result.stdout.truncated).toBe(true)
expect(result.stdout.text.length).toBeLessThanOrEqual(500)
expect(result.stdout.text).toContain('line-0200')
@@ -285,10 +316,10 @@ describe('output truncation and spill', () => {
})
it('does not truncate output exactly at the cap', async () => {
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
))
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text.length).toBe(500)
expect(result.stdout.spillPath).toBeUndefined()
@@ -296,10 +327,10 @@ describe('output truncation and spill', () => {
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
))
expect(failNextClose.value).toBe(false)
expect(result.exitCode).toBe(0)
expect(result.stdout.truncated).toBe(true)
@@ -318,6 +349,19 @@ describe('OutputCollector', () => {
expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
})
it('retains a byte-exact tail across uneven chunk boundaries', () => {
// The old whole-chunk drop could under-retain; a diagnostic tail must be
// exactly the LAST maxBytes regardless of chunking.
const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbbbb'))
collector.push(Buffer.from('cc'))
const out = collector.finalize()
expect(out.text).toBe('aabbbbbbcc')
expect(Buffer.byteLength(out.text)).toBe(10)
expect(out.truncated).toBe(true)
})
it('readFrom returns increments and flags lossy reads', () => {
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('aaaaa'))
@@ -403,38 +447,358 @@ describe('killGroup', () => {
})
it('swallows ESRCH for vanished groups', async () => {
const running = spawnProcess(spec('true'))
const running = spawnSubprocess(spec('true'))
await running.done
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
})
it('handle.kill() after settlement signals nothing and starts no grace timer', async () => {
// Cleanup code commonly kills handles in a finally; after settlement the
// group is gone and the pid may be reused, so a late kill must be inert
// (no signal to a possibly-recycled pgid, no referenced timer delaying exit).
const running = spawnProcess(spec('true'))
it('handle.kill() after the tree died delivers no termination signal', async () => {
// Cleanup code commonly kills handles in a finally; once the tree is gone
// the pid may be reused, so a late kill must deliver nothing (the
// liveness PROBE — signal 0 — is the only process.kill allowed).
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
running.kill()
expect(spy).not.toHaveBeenCalled()
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
expect(delivered).toEqual([])
} finally {
spy.mockRestore()
}
})
})
describe('stdio dispositions', () => {
it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => {
const running = spawnSubprocess({
...spec('cat'),
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } },
})
expect(running.stdin).toBeDefined()
expect(running.stdout).toBeDefined()
expect(running.stderr).toBeUndefined()
expect(running.collected.stdout).toBeUndefined()
expect(running.collected.stderr).toBeDefined()
const echoed = new Promise<string>((resolve) => {
let text = ''
running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') })
running.stdout!.on('end', () => { resolve(text) })
})
running.stdin!.end('through the pipe\n')
const outcome = await running.done
expect(outcome.exitCode).toBe(0)
expect(await echoed).toBe('through the pipe\n')
})
it('a collect mode without spill keeps only the in-memory tail (no file)', async () => {
const running = spawnSubprocess({
...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'),
stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } },
}, { spillDir })
await running.done
const read = running.collected.stdout!.readFrom(0)
expect(read.lossy).toBe(true)
expect(read.text).toContain('line-0200')
expect(read.spillPath).toBeUndefined()
})
})
describe('dispose ladder', () => {
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const running = spawnSubprocess({
...spec('read -r line; exit 0'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
})
await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
const outcome = await running.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
})
it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
const running = spawnSubprocess({
...spec('sleep 60'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
})
await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
const outcome = await running.done
expect(outcome.signal).toBe('SIGTERM')
})
it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
await waitForStdout(running, 'armed\n')
const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
expect(second).toBe(first)
await first
const outcome = await running.done
expect(outcome.signal).toBe('SIGKILL')
})
})
describe('windows tree semantics (injected platform)', () => {
it('kill and terminate route through taskkill by root pid', async () => {
const killed: number[] = []
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
spillDir,
platform: 'win32',
taskkill: (pid) => {
killed.push(pid)
// Simulate the forced tree termination taskkill performs.
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already gone — matches taskkill's tolerated not-found status.
}
},
})
running.terminate()
const outcome = await running.done
expect(killed).toContain(running.pid)
expect(outcome.signal).toBe('SIGKILL')
})
it('waitForExit falls back to direct-child liveness where groups do not exist', async () => {
const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} })
await running.done
await expect(running.waitForExit()).resolves.toBe(true)
})
})
describe('waitForExit', () => {
it('waits for the whole detached tree, not just the shell', async () => {
const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`)
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const grandchild = await waitForPidFile(pidFile)
running.terminate()
await running.done
await expect(running.waitForExit()).resolves.toBe(true)
await expect(waitGone(grandchild, 100)).resolves.toBeUndefined()
})
it('an aborted wait reports false while the tree lives', async () => {
const running = spawnSubprocess(spec('sleep 60'))
const controller = new AbortController()
controller.abort()
await expect(running.waitForExit(controller.signal)).resolves.toBe(false)
running.terminate()
await running.done
})
})
describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
// The leader spawns a TERM-trapping helper with all stdio detached from
// the collected pipes, then exits: the helper holds the GROUP alive while
// the direct child settles. The escalation must still reach it.
const pidFile = join(spillDir, `survivor-${Date.now()}.pid`)
const running = spawnSubprocess(spec(
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`,
{ graceMs: 300 },
))
const helper = await waitForPidFile(pidFile)
await running.done // direct child settled; helper survives in the group
expect(() => process.kill(helper, 0)).not.toThrow()
running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group
await expect(running.waitForExit()).resolves.toBe(true)
await waitGone(helper)
})
it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
const running = spawnSubprocess(spec(
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
{ graceMs: 200 },
))
const helper = await waitForPidFile(pidFile)
await running.done
expect(() => process.kill(helper, 0)).not.toThrow()
await running.dispose({ eofGraceMs: 100, graceMs: 300 })
// The ladder only returns once the WHOLE tree is gone.
expect(() => process.kill(helper, 0)).toThrow()
})
it('service teardown awaits tree survivors, not just handle settlement', async () => {
const { Context } = await import('cordis')
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as InstanceType<typeof LocalSubprocessService>).internals = { spillDir }
const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`)
const running = ctx.subprocess.spawn(spec(
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
{ graceMs: 200 },
))
const helper = await waitForPidFile(pidFile)
await running.done
await fiber.dispose()
// Teardown itself waited for the survivor to die.
expect(() => process.kill(helper, 0)).toThrow()
})
})
describe('coverage seams', () => {
it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
expect(() => { taskkillProcessTree(-1) }).not.toThrow()
expect(() => { taskkillProcessTree(0) }).not.toThrow()
// On POSIX there is no taskkill; spawnSync reports the failure in its
// result and the function stays silent — the same containment Windows
// relies on for an already-absent tree.
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
})
it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
await expect(running.done).rejects.toThrow()
await expect(disposal).resolves.toBeUndefined()
})
it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
const running = spawnSubprocess({
...spec('echo to-parent; echo err >&2'),
stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } },
})
const outcome = await running.done
expect(outcome.exitCode).toBe(0)
expect(running.stdout).toBeUndefined()
expect(running.collected.stdout).toBeUndefined()
expect(running.collected.stderr!.readFrom(0).text).toBe('err\n')
})
it('terminate() after the tree died delivers no termination signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
running.terminate()
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
expect(delivered).toEqual([])
} finally {
spy.mockRestore()
}
})
it('waitForExit on a failed spawn reports exited immediately', async () => {
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' }))
await expect(running.done).rejects.toThrow()
await expect(running.waitForExit()).resolves.toBe(true)
})
it('dispose() on an already-exited tree returns without delivering a signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
expect(delivered).toEqual([])
} finally {
spy.mockRestore()
}
})
it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
expect(running.stdin).toBeUndefined()
await running.done
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
})
})
describe('coverage seams 2', () => {
it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => {
let killedPid = 0
const running = spawnSubprocess(spec('sleep 60'), {
spillDir,
platform: 'win32',
taskkill: (pid) => {
killedPid = pid
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already gone.
}
},
})
const aborted = new AbortController()
aborted.abort()
await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch
running.terminate()
await running.done
expect(killedPid).toBe(running.pid)
await expect(running.waitForExit()).resolves.toBe(true)
})
it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
const kills: number[] = []
const running = spawnSubprocess({
...spec('sleep 60'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
}, {
spillDir,
platform: 'win32',
taskkill: (pid) => {
kills.push(pid)
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already gone.
}
},
})
await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
// Exactly one forced tree termination: no POSIX SIGTERM tier ran.
expect(kills).toEqual([running.pid])
})
it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
// An inert taskkill simulates a tree that never reports exit.
const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
.rejects.toThrow(/did not exit within 40ms after forced termination/)
// Real cleanup: the injected platform spawned without detachment, so the
// child is a plain (group-less) POSIX process — kill it directly.
process.kill(running.pid, 'SIGKILL')
await running.done
})
it("stderr: 'pipe' exposes the raw stream", async () => {
const running = spawnSubprocess({
...spec('echo err >&2'),
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' },
})
expect(running.stderr).toBeDefined()
const text = new Promise<string>((resolve) => {
let out = ''
running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') })
running.stderr!.on('end', () => { resolve(out) })
})
await running.done
expect(await text).toBe('err\n')
})
})
describe('argv validation', () => {
it('rejects an empty argv before spawning', () => {
expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
})
it('rejects an empty program name before spawning', () => {
expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
})
it('spawns argv verbatim without shell interpretation', async () => {
const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done
const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }))
expect(result.stdout.text).toBe('$HOME')
})
})
@@ -449,14 +813,14 @@ describe('abort edge cases', () => {
addEventListener() {},
removeEventListener() {},
} as unknown as AbortSignal
expect(() => spawnProcess(spec('echo hi', { signal: bare })))
expect(() => spawnSubprocess(spec('echo hi', { signal: bare })))
.toThrow(/aborted before spawn: aborted/)
})
it('reports the terminating signal of an externally self-killed command', async () => {
// spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
const result = await spawnProcess(spec('kill -TERM $$')).done
const result = await finish(spawnSubprocess(spec('kill -TERM $$')))
expect(result.signal).toBe('SIGTERM')
})
})
@@ -467,7 +831,7 @@ describe('environment and spill-file hardening', () => {
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')))
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
@@ -479,9 +843,9 @@ describe('environment and spill-file hardening', () => {
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
process.env.DSH_STALE = 'old-value'
try {
const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
})).done
})))
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
} finally {
delete process.env.DSH_STALE
@@ -489,21 +853,21 @@ describe('environment and spill-file hardening', () => {
})
it('rejects DSH variables on the ordinary env channel', () => {
expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
})
it('rejects ordinary variables on the managed env channel', () => {
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
expect(() => spawnProcess(spec('true', { dshEnv: invalid })))
expect(() => spawnSubprocess(spec('true', { dshEnv: invalid })))
.toThrow(/managed child env.*PATH.*use env/)
})
it('creates spill files with owner-only permissions and random names', async () => {
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
))
const path = result.stdout.spillPath!
expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
const mode = statSync(path).mode & 0o777
@@ -511,9 +875,9 @@ describe('environment and spill-file hardening', () => {
})
it('defaults spills into a private per-process directory', async () => {
const result = await spawnProcess(
const result = await finish(spawnSubprocess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
).done
))
const dir = dirname(result.stdout.spillPath!)
expect(dir).toMatch(/dsh-subprocess-/)
const mode = statSync(dir).mode & 0o777
@@ -533,7 +897,7 @@ describe('environment and spill-file hardening', () => {
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
@@ -17,6 +17,9 @@
{
"path": "../subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
@@ -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: 126f0fd6863739563b8d3cb5b4b958cee47dfb3d
README.zh.md: fdd5035867316349a91d1700cbf3f521a2bac117
README.md: 73e0a4abe49e8d3060f246218694faee127668e9
README.zh.md: c38a1dd7c15e7d1c0f3139f8942911a4cd9f23fe
+9 -8
View File
@@ -6,13 +6,14 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes
## Contract
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification).
- Disposal kills all still-running managed processes and awaits their exit.
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification).
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function.
- Disposal of the service terminates all still-running managed processes and awaits their exit.
See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
## Model Experience
@@ -24,5 +25,5 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract.
- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story.
- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced.
- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits.
+9 -8
View File
@@ -6,13 +6,14 @@
## 契约
- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时 resolve,仅在 spawn 层面失败时 reject。
- spec 完全显式(argv、cwd、按流划分的字节上限、spill 上限、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 在这里绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- 输出读取器接受全流字节偏移量且从不消费独立的读取器不会抢走彼此的增量偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在完整流 spill 文件存在时指向它。
- `kill()` 与 spec 的 abort 信号对整个 detached 进程组执行 SIGTERM→宽限期→SIGKILL 升级;服务响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。
- dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出
- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACPAgent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入该函数
- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。
参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
## 模型体验
@@ -24,5 +25,5 @@
## 已知限制与暂缓事项
- **目前只有一个消费方家族**:该 seam 的形状仅在 bash 执行器上得到验证;仓库内其他 spawn 调用点(LSP 服务器、PTY 后端、subagent 传输层)继续保留各自专属的进程处理,直到它们的流与生命周期需求对照本契约得到重新审视
- **假定 POSIX 进程组语义**:句柄词汇(作为组长的 `pid`、进程组终止、SIGTERM/SIGKILL 升级)没有 Windows 方案
- **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seamfork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源
- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用
+55 -13
View File
@@ -1,14 +1,17 @@
/**
* The subprocess seam (`ctx.subprocess`): spawn fully-specified
* commands into managed process groups with bounded, spill-backed output and
* escalated kills. Command defaulting, shell semantics, deadlines, and
* presentation belong to consumers — the bash executor seam is the owning
* The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into
* managed process trees with Node-shaped stdio dispositions — raw pipes for
* protocol streams, inherit for diagnostics, bounded spill-backed collection
* for batch output — plus tree-scoped signalling and a cooperative dispose
* ladder. Command defaulting, shell semantics, deadlines, framing, and
* presentation belong to consumers; the bash executor seam is the owning
* template. The local implementation lives in
* `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess
*/
import { Context, Service } from 'cordis'
import { DSH_ENV_PREFIX } from './types.ts'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
@@ -16,13 +19,48 @@ export type {
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
SubprocessCollect,
SubprocessCollectedOutputs,
SubprocessDisposeGraces,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
SubprocessOutputRead,
SubprocessOutputReader,
SubprocessSpawnSpec,
SubprocessStdinMode,
SubprocessStdio,
} from './types.ts'
/**
* Credential-shaped environment names are NOT forwarded to children (the
* harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
* process implicitly). One heuristic for every in-repo spawner; a
* deliberately supplied entry survives because explicit env layers merge
* after the scrub.
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* The ambient parent environment minus credential-shaped names and minus all
* `DSH_*` names — the canonical base every harness child starts from. `PATH`,
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
* harness identity never leaks implicitly (a child that needs current `DSH_*`
* facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a
* deliberately forwarded credential goes through an explicit env layer, which
* merges after this scrub). Exported as a plain function so spawners that
* cannot route through the service (node-pty backends, SDK-managed
* transports) share the one scrub definition.
* @returns a fresh environment object safe to hand to a child spawn.
*/
export function scrubbedParentEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
}
return env
}
declare module 'cordis' {
interface Context {
subprocess: SubprocessService
@@ -37,13 +75,17 @@ declare module 'cordis' {
*
* Implementations must honor these semantics:
* - {@link spawn} returns immediately with a live handle; `done` resolves at
* process close and rejects only for spawn-level failures.
* - Output readers are offset-based and non-consuming, so independent readers
* never consume one another's output; lossy reads report truncation and the
* spill file holding the complete stream when one exists.
* - {@link SubprocessHandle.kill} and the spec's abort signal escalate
* SIGTERM→grace→SIGKILL across the whole process group.
* - Disposal kills all still-running managed processes and awaits their exit.
* process close with exit facts and rejects only for spawn-level failures.
* - Collect-mode readers are offset-based and non-consuming, so independent
* readers never consume one another's output; lossy reads report truncation
* and the spill file holding the complete stream when one exists. Piped
* streams are handed to the caller raw and never buffered here.
* - {@link SubprocessHandle.kill} signals without escalation,
* {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates
* SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the
* cooperative EOF-first ladder — all tree-scoped on every platform.
* - Disposal of the service terminates all still-running managed processes
* and awaits their exit.
*/
export abstract class SubprocessService extends Service {
constructor(ctx: Context) {
@@ -53,8 +95,8 @@ export abstract class SubprocessService extends Service {
/**
* Start one managed child process from a fully-specified spec; this seam
* applies no defaults.
* @param spec - argv, directory, limits, grace, cancellation, and environment.
* @returns the live process handle (readers, kill, outcome promise).
* @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
* @returns the live process handle (streams/readers, signalling, outcome promise).
*/
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
}
+155 -45
View File
@@ -1,11 +1,14 @@
/**
* Vocabulary for the subprocess seam: fully-specified spawn requests,
* bounded output with spill recovery, and live process handles. Command
* defaulting, shell semantics, and presentation belong to consumers such as
* the bash executor seam.
* Vocabulary for the subprocess seam: fully-specified spawn requests with
* Node-shaped per-stream stdio modes, bounded collected output with spill
* recovery, raw piped streams, and tree-scoped termination. Command
* defaulting, shell semantics, protocol framing, and presentation belong to
* consumers such as the bash executor seam.
* @module dsh-subprocess/types
*/
import type { Readable, Writable } from 'node:stream'
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
@@ -26,60 +29,97 @@ export interface CollectedOutput {
}
/**
* A fully-specified spawn request. This seam applies no defaults: every limit
* and directory is explicit, so the caller's own config — not a hidden
* subprocess-service default — decides them (the `dsh-bash` request/spec split
* is the owning template).
* stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes
* {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes;
* `{ data }` writes the bytes and closes (the batch shape).
*/
export type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string }
/**
* Bounded in-memory collection for one output stream, with an optional
* full-stream spill file. Omitting `spill` keeps only the in-memory tail —
* the diagnostic-tail shape (a language server's stderr); including it makes
* the complete stream recoverable up to its cap (the bash tool shape).
*/
export interface SubprocessCollect {
/** In-memory cap in bytes; overflow keeps the TAIL. */
maxBytes: number
/** Full-stream spill file; absent disables spilling entirely. */
spill?: {
/** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */
maxBytes: number
}
}
/**
* stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the
* caller's protocol decoding; `'inherit'` passes the parent's descriptor
* through (child diagnostics land on the harness's own stream); a
* {@link SubprocessCollect} object buffers boundedly with offset-based reads.
*/
export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect
/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */
export interface SubprocessStdio {
stdin: SubprocessStdinMode
stdout: SubprocessOutputMode
stderr: SubprocessOutputMode
}
/**
* A fully-specified spawn request. This seam applies no defaults: every
* disposition, limit, and directory is explicit, so the caller's own config —
* not a hidden subprocess-service default — decides them (the `dsh-bash`
* request/spec split is the owning template).
*/
export interface SubprocessSpawnSpec {
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
argv: readonly string[]
/** Working directory for the child. */
cwd: string
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes: number
/** Grace period for kill escalation and for inherited pipes after process exit. */
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
*/
graceMs: number
/**
* Abort signal — kills the process group when it fires. The caller owns
* deadlines and cause classification; this seam only reacts to the abort.
* Abort signal — starts the terminate escalation on the process tree when
* it fires. The caller owns deadlines and cause classification; this seam
* only reacts to the abort.
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty.
*/
stdin?: string | undefined
/**
* Ordinary environment entries merged after the implementation's credential
* scrub. `DSH_*` names are rejected and belong in {@link dshEnv}.
* Ordinary environment entries merged onto the implementation's scrubbed
* parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and
* belong in {@link dshEnv}; a deliberately forwarded credential-shaped
* entry survives because this layer merges after the scrub.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution. Implementations
* discard ambient `DSH_*` entries before merging this snapshot, so an
* unavailable current fact cannot inherit a stale value from the harness
* process, and reject non-`DSH_*` names supplied through this channel.
* Harness-owned `DSH_*` variables for this execution. The scrubbed base has
* already discarded ambient `DSH_*` entries, so an unavailable current fact
* cannot inherit a stale value from the harness process; non-`DSH_*` names
* on this channel are rejected.
*/
dshEnv?: DshEnvironment | undefined
}
/**
* Raw outcome of one closed process. Deliberately carries NO timeout or
* cancellation classification: the service kills on abort but does not decide
* why — the caller reads the signal it owns to classify causes.
* Exit facts of one closed process — Node's `close`-event vocabulary.
* Deliberately carries NO timeout or cancellation classification (the caller
* reads the signal it owns to classify causes) and NO output: collected
* streams stay readable through {@link SubprocessHandle.collected} after
* settlement, so batch and streaming callers share one access path.
*/
export interface SubprocessOutcome {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
stdout: CollectedOutput
stderr: CollectedOutput
}
/** One incremental {@link SubprocessOutputReader.readFrom} read. */
@@ -95,9 +135,11 @@ export interface SubprocessOutputRead {
}
/**
* Cursor-free incremental access to one live output stream. Offsets are
* Cursor-free incremental access to one collected output stream. Offsets are
* whole-stream byte coordinates owned by the caller, so independent readers
* cannot consume one another's output.
* cannot consume one another's output; `readFrom(0)` after settlement is the
* batch result (`lossy` then means the in-memory tail lost its head — the
* {@link CollectedOutput.truncated} fact).
*/
export interface SubprocessOutputReader {
/**
@@ -110,19 +152,87 @@ export interface SubprocessOutputReader {
readFrom(fromByte: number): SubprocessOutputRead
}
/** Offset-based readers for the streams spawned in collect mode. */
export interface SubprocessCollectedOutputs {
/** Present iff stdout is a {@link SubprocessCollect}. */
readonly stdout?: SubprocessOutputReader
/** Present iff stderr is a {@link SubprocessCollect}. */
readonly stderr?: SubprocessOutputReader
}
/**
* A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
* escalation; buffered output remains readable after exit.
* The two grace periods of the cooperative dispose ladder
* ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted,
* validated Config fields, so teardown timing is deployment-tunable and this
* seam hardcodes nothing.
*/
export interface SubprocessDisposeGraces {
/**
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
* ON ITS OWN — flush durable state, tear down its own descendants — before
* escalation to platform termination. Usually WIDER than
* {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven
* teardown may itself wait on a signal-trapping grandchild plus a final
* flush.
*/
eofGraceMs: number
/**
* Termination confirmation window (ms): POSIX applies it after `SIGTERM`
* and again after `SIGKILL`; Windows applies it after the forced tree
* termination.
*/
graceMs: number
}
/**
* A live child process rooted in its own process tree. Collected output
* remains readable after exit; piped streams belong to the caller.
*
* Termination is tree-scoped everywhere: POSIX signals the detached process
* group (falling back to the direct child when the group is gone), Windows
* terminates the tree via `taskkill /T`, so helper processes cannot outlive
* the handle unnoticed.
*/
export interface SubprocessHandle {
/** Process id (group leader); -1 when the spawn itself failed. */
/** Process id (tree root); -1 when the spawn itself failed. */
readonly pid: number
/** Live stdout reader (also readable after exit). */
readonly stdout: SubprocessOutputReader
/** Live stderr reader (also readable after exit). */
readonly stderr: SubprocessOutputReader
/** Resolves when the process closes; rejects only for spawn-level failures. */
/** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
readonly stdin: Writable | undefined
/** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */
readonly stdout: Readable | undefined
/** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */
readonly stderr: Readable | undefined
/** Offset-based readers for collect-mode streams (also readable after exit). */
readonly collected: SubprocessCollectedOutputs
/** Resolves at process close with exit facts; rejects only for spawn-level failures. */
readonly done: Promise<SubprocessOutcome>
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
kill(): void
/**
* Send one signal to the process tree, Node-style — no escalation, no
* timers. A no-op after the outcome has settled (the pid may be reused).
* @param signal - the signal to deliver (default `SIGTERM`; Windows
* force-terminates the tree for any value).
*/
kill(signal?: NodeJS.Signals): void
/**
* Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
* (Windows force-terminates immediately). Idempotent; also triggered by the
* spec's abort signal.
*/
terminate(): void
/**
* Wait until the process tree has exited — the tree, not just the direct
* child, so a still-running helper is observable before teardown returns.
* @param signal - optional bound for the wait.
* @returns `true` when the tree exited, `false` when the signal aborted first.
*/
waitForExit(signal?: AbortSignal): Promise<boolean>
/**
* Tear the child down to quiescence, resolving only after exit: close stdin
* (when this handle owns a piped one) and allow cooperative flush for
* `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced
* tree termination with a final bounded `graceMs` wait.
* @param graces - the ladder's two windows, from the consumer's Config.
* @throws when the child still has not exited `graceMs` after the forced tier.
*/
dispose(graces: SubprocessDisposeGraces): Promise<void>
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/**
* Minimal concrete service: a hand-built handle. The seam is spawn-only —
@@ -11,18 +11,20 @@ import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from
class StubSubprocessService extends SubprocessService {
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false }
let killed = false
const collected = spec.stdio.stdout !== 'pipe' && spec.stdio.stdout !== 'inherit'
? { stdout: { readFrom: () => read } }
: {}
return {
pid: spec.argv.length,
stdout: { readFrom: () => read },
stderr: { readFrom: () => read },
done: Promise.resolve({
exitCode: killed ? null : 0,
signal: null,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}),
kill: () => { killed = true },
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected,
done: Promise.resolve({ exitCode: 0, signal: null }),
kill: () => {},
terminate: () => {},
waitForExit: () => Promise.resolve(true),
dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(),
}
}
}
@@ -34,22 +36,40 @@ describe('SubprocessService seam', () => {
const handle = ctx.subprocess.spawn({
argv: ['true'],
cwd: '/stub',
stdoutMaxBytes: 1,
stderrMaxBytes: 1,
maxSpillBytes: 1,
stdio: { stdin: 'ignore', stdout: { maxBytes: 1 }, stderr: 'inherit' },
graceMs: 1,
})
expect(handle.pid).toBe(1)
expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
handle.kill()
handle.terminate()
await expect(handle.waitForExit()).resolves.toBe(true)
await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined()
const outcome = await handle.done
expect(outcome.stdout.text).toBe('ok')
expect(outcome.exitCode).toBe(0)
})
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => {
const ctx = new Context()
await ctx.plugin(StubSubprocessService)
class SecondManager extends StubSubprocessService {}
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
class SecondService extends StubSubprocessService {}
await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/)
})
it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => {
process.env.DSH_SCRUB_PROBE = 'stale'
process.env.SCRUB_PROBE_TOKEN = 'secret'
process.env.SCRUB_PROBE_PLAIN = 'visible'
try {
const env = scrubbedParentEnv()
expect(env.DSH_SCRUB_PROBE).toBeUndefined()
expect(env.SCRUB_PROBE_TOKEN).toBeUndefined()
expect(env.SCRUB_PROBE_PLAIN).toBe('visible')
expect(env.PATH).toBeDefined()
} finally {
delete process.env.DSH_SCRUB_PROBE
delete process.env.SCRUB_PROBE_TOKEN
delete process.env.SCRUB_PROBE_PLAIN
}
})
})