@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. 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.
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): 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):
- stdin EOF (when stdin is piped), then wait
graces.disposeEofGraceMs— a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; SIGTERM, then waitgraces.disposeGraceMs;SIGKILL, then await the now-certain exit — a child that ignores EOF and trapsSIGTERMcannot 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)
mkdtempdir under the OS temp root;remove()deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent. - Pinned (
pinnedPathset): 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 exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end.
Model Experience
Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories.
Known Limitations and Deferred Work
- The credential scrub is name-based — only variables matching
KEY/SECRET/TOKENare removed; differently named secrets such asPASSWORDpass 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
rmfailure 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.