subprocess: one explicit env channel on the spawn spec

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.
This commit is contained in:
Tianyi Cui
2026-07-27 04:14:51 +08:00
parent cb1864795e
commit 5717726835
43 changed files with 134 additions and 201 deletions

View File

@@ -12,7 +12,6 @@
import { Context, Service } from 'cordis'
import { DSH_ENV_PREFIX } from './types.ts'
import type { DshEnvironment, DshEnvironmentKey } from './types.ts'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
@@ -46,11 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
* The ambient parent environment minus credential-shaped names and minus all
* `DSH_*` names — the canonical base every harness child starts from. `PATH`,
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
* harness identity never leaks implicitly (a child that needs current `DSH_*`
* facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a
* deliberately forwarded credential goes through an explicit env layer, which
* merges after this scrub). Exported as a plain function so spawners that
* cannot route through the service (node-pty backends, SDK-managed
* harness identity never leaks implicitly (a deliberately forwarded
* credential or current `DSH_*` fact goes through the spec's explicit `env`,
* which merges after this scrub). Exported as a plain function so spawners
* that cannot route through the service (node-pty backends, SDK-managed
* transports) share the one scrub definition.
* @returns a fresh environment object safe to hand to a child spawn.
*/
@@ -62,27 +60,6 @@ export function scrubbedParentEnv(): Record<string, string> {
return env
}
/**
* Partition one mixed explicit-env map onto the spec's two channels: `DSH_*`
* names are deployment-owned facts for the child and take the managed
* {@link SubprocessSpawnSpec.dshEnv} channel (the ordinary channel rejects the
* reserved namespace), everything else stays ordinary `env`. For consumers
* whose configs expose a single env map (lsp-local servers, the ACP backend)
* rather than two channel-shaped fields.
* @param env - explicit entries from a consumer's config, both namespaces mixed.
* @returns the two spec channels, each safe for its validator.
*/
export function splitEnvChannels(env: Readonly<Record<string, string>>): { env: Record<string, string>; dshEnv: DshEnvironment } {
const ordinary: Record<string, string> = {}
const managed: Record<DshEnvironmentKey, string> = {}
const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX)
for (const [key, value] of Object.entries(env)) {
if (isDshKey(key)) managed[key] = value
else ordinary[key] = value
}
return { env: ordinary, dshEnv: managed }
}
declare module 'cordis' {
interface Context {
subprocess: SubprocessService