Files
deepseek-harness/packages/subagent/subagent-subprocess/README.md
2026-07-13 15:47:46 +08:00

5.5 KiB

@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):

  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 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 / 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.