rename: @deepseek-ai/dsh-subagent-process -> @deepseek-ai/dsh-subagent-subprocess
The extracted library's name sat one edit away from @deepseek-ai/dsh-subagent-inprocess (process/inprocess), inviting a typo'd import to silently resolve to the wrong package. subagent-subprocess also reads as the deliberate counterpart to subagent-inprocess (in-process vs. subprocess), matching how the two shared drivers actually differ. Package directory, npm name, module doc, JSDoc module tags, test-file name and its temp-dir prefixes, the subagent-acp import and its Config/tsconfig/package.json references, root tsconfig.json/tsconfig.build.json/knip.json entries, and the packages/subagent group README all renamed together; regenerated docs/module-graph.md and docs/config-catalog.md. Pure rename — no behavior, export, or Config shape changed.
This commit is contained in:
40
packages/subagent/subagent-subprocess/README.md
Normal file
40
packages/subagent/subagent-subprocess/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @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 RFC](../../../docs/rfc/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
|
||||
|
||||
### `SENSITIVE_ENV_PATTERN` / `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.
|
||||
|
||||
### `waitForExit(child)` / `exitsWithin(child, ms)`
|
||||
|
||||
Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child.
|
||||
|
||||
### `disposeChildProcess(child, graces)`
|
||||
|
||||
The three-tier 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. `SIGTERM`, then wait `graces.disposeGraceMs`;
|
||||
3. `SIGKILL`, then await the now-certain exit — a child that ignores EOF and traps `SIGTERM` cannot wedge dispose forever.
|
||||
|
||||
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush.
|
||||
|
||||
### `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 the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end.
|
||||
30
packages/subagent/subagent-subprocess/package.json
Normal file
30
packages/subagent/subagent-subprocess/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
219
packages/subagent/subagent-subprocess/src/index.ts
Normal file
219
packages/subagent/subagent-subprocess/src/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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. The pieces: the credential env scrub
|
||||
* ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure
|
||||
* capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} /
|
||||
* {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder
|
||||
* ({@link disposeChildProcess}), and the per-run isolated config dir
|
||||
* ({@link createIsolatedConfigDir}).
|
||||
*
|
||||
* This package owns no provider and registers nothing; it is a pure library
|
||||
* the out-of-process backend packages depend on (the `subagent-inprocess`
|
||||
* shape, for the process boundary). Every tunable — the ladder's grace
|
||||
* periods, a pinned config dir — is a PARAMETER here: defaults belong in each
|
||||
* consuming plugin's Config, per the no-hardcoded-tunables rule.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
export 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 {@link SENSITIVE_ENV_PATTERN}-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 failure as a promise the run's result path
|
||||
* can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an
|
||||
* `error` EVENT, not a thrown exception — and without a listener Node treats
|
||||
* it as an unhandled error and crashes the parent process. Call this in the
|
||||
* SAME TICK as `spawn()`, so no window exists for the event to fire unheard.
|
||||
* @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) })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the child process exits (any code/signal); immediate if it is
|
||||
* already gone.
|
||||
* @param child - the child process to await.
|
||||
*/
|
||||
export function waitForExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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 `SIGTERM`. 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
|
||||
/** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */
|
||||
disposeGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear a child process down to QUIESCENCE: resolves only once the child has
|
||||
* actually exited (or was already gone), never merely after requesting it.
|
||||
* Three-tier escalation —
|
||||
*
|
||||
* 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a
|
||||
* cooperative child quiesces on its own, its teardown and flushes intact;
|
||||
* 2. `SIGTERM`, then wait `disposeGraceMs`;
|
||||
* 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF
|
||||
* and traps `SIGTERM` must not wedge dispose forever.
|
||||
*
|
||||
* @param child - the child process to tear down.
|
||||
* @param graces - the two grace periods, from the consuming plugin's Config.
|
||||
*/
|
||||
export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
|
||||
// Already gone: nothing to reap.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
// 1. Graceful: end the request stream (stdin EOF) and let the child quiesce
|
||||
// on its own. Sending SIGTERM in the same tick (or too soon) would
|
||||
// default-terminate a cooperative child mid-flush, orphaning its nested
|
||||
// work. A child spawned without a stdin pipe skips straight to the wait.
|
||||
child.stdin?.end()
|
||||
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
||||
// 2. SIGTERM, escalating if the child still does not exit within the grace.
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
||||
// 3. Force-kill and await the (now-certain) exit.
|
||||
child.kill('SIGKILL')
|
||||
await waitForExit(child)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, so the child's behavior is a
|
||||
* function of deployment config alone — never of whatever `~/.claude` /
|
||||
* `~/.codex`-style state happens to exist on the host machine. Two modes:
|
||||
*
|
||||
* - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp`
|
||||
* dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it
|
||||
* best-effort;
|
||||
* - `pinnedPath` set (a deployment deliberately sharing state across runs):
|
||||
* the pinned path is returned as-is — never created, never removed — the
|
||||
* deployment owns that directory's lifecycle.
|
||||
*
|
||||
* @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). The dir lives
|
||||
// under the OS temp root, which reclaims it; failing dispose over
|
||||
// cleanup would be worse than a leftover temp dir.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
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,
|
||||
exitsWithin,
|
||||
SENSITIVE_ENV_PATTERN,
|
||||
spawnFailure,
|
||||
waitForExit,
|
||||
} from '../src/index.ts'
|
||||
|
||||
// `rm` is wrapped (real-passthrough by default) so ONE test can inject a
|
||||
// rejection deterministically. A real recursive-rm failure is not portably
|
||||
// provokable — permission tricks (a chmod-000 subtree) fail only for
|
||||
// unprivileged users and are ignored by root — so this is the fs boundary
|
||||
// the testing policy sanctions mocking; everything else stays the real fs.
|
||||
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
|
||||
/** `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
|
||||
setTimeout(() => {
|
||||
if (trigger === 'eof') this.exitCode = 0
|
||||
else this.signalCode = trigger
|
||||
this.emit('exit', this.exitCode, this.signalCode)
|
||||
}, 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 / SENSITIVE_ENV_PATTERN', () => {
|
||||
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(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false)
|
||||
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 waitForExit(asChild(fake))
|
||||
// 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('waitForExit / exitsWithin', () => {
|
||||
it('resolves immediately for a child that already exited by code', async () => {
|
||||
const fake = new FakeChild()
|
||||
fake.exitCode = 0
|
||||
await expect(waitForExit(asChild(fake))).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves immediately for a child that already died by signal', async () => {
|
||||
const fake = new FakeChild()
|
||||
fake.signalCode = 'SIGTERM'
|
||||
await expect(waitForExit(asChild(fake))).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves on the exit event of a live child', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
const exited = waitForExit(asChild(fake))
|
||||
fake.kill('SIGTERM')
|
||||
await expect(exited).resolves.toBeUndefined()
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => {
|
||||
const fake = new FakeChild()
|
||||
fake.exitCode = 0
|
||||
await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('exitsWithin resolves true when the child exits inside the window', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
fake.kill('SIGTERM')
|
||||
await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true)
|
||||
// The once-listener fired and the grace timer was cleared — nothing lingers.
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('exitsWithin resolves false on timeout for a child that never exits', async () => {
|
||||
const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent
|
||||
await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false)
|
||||
// The timeout arm removed its exit listener: repeated waits (a poll loop,
|
||||
// the ladder's tiers) never accumulate listeners on the same child.
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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('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 })
|
||||
expect(fake.stdinEnded).toBe(true)
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
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 })
|
||||
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('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 })
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
// Private (0700) per the defensive-patterns temp-dir rule.
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
11
packages/subagent/subagent-subprocess/tsconfig.json
Normal file
11
packages/subagent/subagent-subprocess/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user