diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..39e7915bea 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md +subprocess.md: a97e407290aa12881e7d6e847d51fec13c134135 +subprocess.zh.md: e0b6f3fc2249d7095b5e540f91a0f13b544db978 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 922e7ad0ee..a97e407290 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate string entry, while an explicit `undefined` tombstone removes an ordinary ambient value. Each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..e0b6f3fc22 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -8,7 +8,7 @@ ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的字符串条目形式到达,而显式的 `undefined` tombstone 会删除普通环境中已有的值。每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f9af11105..2c62887faf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2787,7 +2787,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: NodeJS.ProcessEnv | undefined;\n}', }, { name: 'SubprocessStdinMode', diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index e27e0a6649..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -10,9 +10,10 @@ import type { SpawnedProcess, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' -import type { - SubprocessHandle, - SubprocessSpawnSpec, +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' function thrown(value: unknown): Error { @@ -21,19 +22,18 @@ function thrown(value: unknown): Error { } /** - * Convert the SDK environment to the shared subprocess seam's defined-value - * overlay without changing the effective child environment. - * @param env - SDK-composed child environment. - * @returns entries whose values survive Node's subprocess environment. + * Encode the SDK's complete child environment as a subprocess overlay. + * @param env - SDK-composed child environment after its removals and replacements. + * @returns explicit values plus tombstones for surviving ambient names the SDK removed. */ -export function definedEnvironment( +export function sdkEnvironmentOverlay( env: SpawnOptions['env'], -): Record { - const defined: Record = {} - for (const [name, value] of Object.entries(env)) { - if (value !== undefined) defined[name] = value +): NodeJS.ProcessEnv { + const overlay: NodeJS.ProcessEnv = { ...env } + for (const name of Object.keys(scrubbedParentEnv())) { + if (!(name in env)) overlay[name] = undefined } - return defined + return overlay } /** @@ -55,7 +55,7 @@ export function claudeSpawnSpec( stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: definedEnvironment(options.env), + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 0fa781c082..3dab6ab5cd 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -31,8 +31,8 @@ import * as claudeCode from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { claudeSpawnSpec, - definedEnvironment, ManagedClaudeCodeProcess, + sdkEnvironmentOverlay, } from '../src/process.ts' import { claudeQueryOptions, @@ -386,6 +386,7 @@ describe('task admission and package contracts', () => { describe('official spawn projection', () => { it('forwards command, arguments, cwd, environment, and signal exactly', () => { + vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value') const signal = new AbortController().signal const options = sdkSpawnOptions({ command: '/official/claude', @@ -394,15 +395,26 @@ describe('official spawn projection', () => { env: { A: 'one', B: undefined, C: 'three' }, signal, }) - expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' }) - expect(claudeSpawnSpec(options, 321)).toEqual({ + expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) + const spawnSpec = claudeSpawnSpec(options, 321) + expect(spawnSpec).toMatchObject({ argv: ['/official/claude', '--one', 'two'], cwd: '/parent/workspace', stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: 321, signal, - env: { A: 'one', C: 'three' }, }) + expect(spawnSpec.env).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) const missingCwd = sdkSpawnOptions() delete missingCwd.cwd expect(() => claudeSpawnSpec( diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 932daa2c59..d3cad162fb 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -26,12 +26,12 @@ import type { /** * Build a child environment: explicit caller entries merge after the scrubbed - * parent base, so a deliberately supplied credential or current `DSH_*` fact - * wins over the scrub that dropped its ambient namesake. - * @param extra - explicit caller entries, merged verbatim after the scrub. + * parent base. A string deliberately restores or overrides an entry; an + * explicit `undefined` tombstone removes an ordinary ambient entry. + * @param extra - explicit caller entries and tombstones, merged after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { +export function childEnv(extra?: Readonly): NodeJS.ProcessEnv { return { ...scrubbedParentEnv(), ...extra } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ad2fc0f30a..224476b8d5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -343,6 +343,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('alpha/beta\n') }) + it('lets an explicit tombstone remove an ordinary ambient env entry', async () => { + process.env.SUBPROCESS_TOMBSTONE_PROBE = 'ambient-value' + try { + const result = await finish(spawnSubprocess(spec( + 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"', + { env: { SUBPROCESS_TOMBSTONE_PROBE: undefined } }, + ))) + expect(result.stdout.text).toBe('absent\n') + } finally { + delete process.env.SUBPROCESS_TOMBSTONE_PROBE + } + }) + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..a5e47747f0 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.md: 13c634429bfae9408dc732aea69df673e5da87aa +README.zh.md: fe7b28d3a8f256e0eb9b4cbb98093bac33816fdf diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..13c634429b 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..fe7b28d3a8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..a21f6ffb0d 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -94,13 +94,12 @@ export interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } /**