feat(subprocess): migrate lsp-local, subagent-acp, and the env scrubs onto the seam
Review direction (tianyicui, PR #660): in a stacked PR, change all other process-running places to use the new service. - lsp-local: LspConnection spawns through ctx.subprocess (piped protocol streams + a no-spill collected stderr tail); its private process-tree helpers (POSIX group signalling, Windows taskkill, liveness polling) are deleted in favor of the seam's handle verbs, and its buildChildEnv now rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*). The plugin injects 'subprocess'; compositions/tests mount dsh-subprocess-local. - subagent-acp: the ACP child spawns through the seam (piped ndjson streams, inherited stderr); spawn failure surfaces through done-rejection into the same startup race; disposal is handle.dispose with the plugin's configured graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub are the seam's, and the isolated-config-dir helper had no consumer. - mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one scrub definition (their spawns stay put by ownership: the MCP SDK and node-pty own those calls; the SDK wizard runs outside any composition). - Coverage: per-file 100% over every touched src file, with each v8 ignore carrying a platform or contract reason; new suites cover stdio dispositions, the dispose ladder tiers, injected-win32 tree semantics, waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal. - Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note updated in place, subprocess.md rewritten for the reshaped vocabulary (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill added to knip ignoreBinaries.
This commit is contained in:
@@ -8,10 +8,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).
|
||||
|
||||
@@ -55,7 +55,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).
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,33 @@ 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.
|
||||
/* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */
|
||||
const spawnFailed: Promise<never> = child.done.then(() => new Promise<never>(() => {}), (err: unknown) => Promise.reject(toError(err)))
|
||||
/* v8 ignore stop */
|
||||
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 +225,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 +270,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) {
|
||||
|
||||
@@ -21,7 +21,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-subprocess"
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# @deepseek-ai/dsh-subagent-subprocess
|
||||
|
||||
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,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user