Drop SubprocessSpawnSpec.dshEnv and splitEnvChannels(); childEnv() is now scrubbed-base + explicit entries with no namespace validation. The invariant dropped is the reserved-namespace check on explicit entries (DSH_* rejected from env, non-DSH_* rejected from dshEnv). Explicit-entry trust already covers it: an explicit credential-shaped entry has always merged after the scrub as a deliberate caller opt-in, and an explicit DSH_* entry is the same deliberate act — the staleness invariant lives entirely in scrubbedParentEnv dropping AMBIENT credential-shaped and DSH_* names, which stays. The validation's only observed effect was rejecting legitimate explicit entries: both recent CI breakages (DSH_GATE_CONCURRENCY exported into every job crashing lsp specs, DSH_PERMISSION_MODE in acp config.env crashing the child spawn) were this check firing on values a caller meant to pass, each fixed by routing around the bureaucracy the seam itself imposed. The bash seam keeps its own request/spec dshEnv field: that is bash-owned trusted-plugin vocabulary (the ctx.bashEnv collected overlay) whose merge-last position guarantees a caller env entry cannot displace a managed fact; bash-local now flattens ENV_OVERRIDES -> spec.env -> spec.dshEnv into the seam's one env map. subagent-acp and lsp-local pass their single config env map straight through. DshEnvironment/DshEnvironmentKey/DSH_ENV_PREFIX stay on the subprocess seam as the namespace vocabulary (bash re-exports them; scrubbedParentEnv filters on the prefix). Tests: the two channel-rejection specs and the splitEnvChannels partition spec are deleted; one spawn spec now proves an explicit DSH_* env entry reaches the child while an ambient one is scrubbed; the acp/lsp forwarding specs keep their MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV assertions with the split comments rewritten to merge-after-scrub. Docs (en+zh, re-recorded) and the owning Agent Notes updated; cordis api/services catalogs regenerated.
7.8 KiB
Agent Note: The subprocess service is its own seam under the bash executors (dsh-subprocess / dsh-subprocess-local)
Status: implemented
English | 中文
Problem
dsh-bash-local bundled two capabilities that change for different reasons: running a bash command (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and running and managing a child process (detached process groups, bounded tail-keep output with spill files, the credential scrub and DSH_* merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — run.ts, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared DSH_*/CollectedOutput vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling task registry, whose registrations deliberately outlive producer fibers.
Decision
A new subprocess/ capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
@deepseek-ai/dsh-subprocess(interface) — the abstractSubprocessServiceowningctx.subprocesswith one method,spawn(spec): SubprocessHandle, and the shared vocabulary: the fully-explicitSubprocessSpawnSpec(argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per thedsh-bashrequest/spec template and the no-hidden-defaults rule),SubprocessHandlewith non-consuming offset-based readers,SubprocessOutcomewith deliberately no timeout/cancel classification, and the shared scrub plusDSH_ENV_PREFIX/DshEnvironment/CollectedOutputtypes.argvis never shell-interpreted. (The consumer-migration Agent Note later widened the stdio and termination vocabulary Node-ward.)@deepseek-ai/dsh-subprocess-local(implementation) —LocalSubprocessServiceover the formerrun.tsplumbing (spawn.ts): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminalENV_OVERRIDES(TERM=dumbetc.) did NOT move — that is bash-tool presentation policy and stays indsh-bash-local, merged through the ordinary env channel.dsh-bash-local(consumer) —inject: ['subprocess']; maps each resolvedBashExecSpeconto aSubprocessSpawnSpec(['bash', '-c', command]), keeps its config,resolve()defaulting, fused-deadlinetimedOut/abortedclassification, the[stderr]-marked background read merge with its consuming cursor, and theonProcessDonesubclass hook.dsh-bash-sandboxis unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.dsh-bash(seam) — re-exports the moved vocabulary fromdsh-subprocess, so no bash consumer changes an import;BashExecRequest/BashExecSpec/BashProcessand the sandbox facts remain bash-owned.
Every composition that loads a bash executor now also loads @deepseek-ai/dsh-subprocess-local (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects done and buffers nothing for a process that never ran), so the executor injects the spawn failed: … note into exactly one readOutput() delta.
Alternatives considered
Leave the process plumbing inside dsh-bash-local (status quo). Rejected for the same reason the task registry split landed: the boundary is stable and already documented in-code (run.ts's module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split.
Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto ctx.subprocess in the same change. Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the consumer-migration Agent Note records the Node-ward reshape and which sites moved (and which stayed, by ownership).
Put run_in_background/task semantics into the process seam instead. Rejected: that boundary already exists — ctx.tasks owns ids, ownership, and notices, and the bash tool adapts a BashProcess into task hooks. The process seam sits below the bash executor, not beside the task registry.
Move ENV_OVERRIDES (TERM=dumb, PAGER=cat …) into the subprocess service. Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and DSH_* names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins.
Consequences
Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: bash-local, bash-sandbox); a containerized or remote process backend slots in without touching bash semantics; the shared DSH_*/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to dsh-subprocess-local (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service.
Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves ctx.bash pending on ctx.subprocess (standard missing-service behavior). The moved-vocabulary re-exports keep dsh-bash imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated readFrom(0) reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists.