Preserve Claude SDK child environment

This commit is contained in:
pku-xht
2026-08-05 01:07:58 +08:00
parent 34b6cb91ed
commit 96d6853a96
12 changed files with 72 additions and 50 deletions
@@ -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
+6 -7
View File
@@ -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<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
```
+6 -7
View File
@@ -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<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
```
@@ -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<string, string> | 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',
@@ -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<string, string> {
const defined: Record<string, string> = {}
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),
}
}
@@ -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(
@@ -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<Record<string, string>>): NodeJS.ProcessEnv {
export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
return { ...scrubbedParentEnv(), ...extra }
}
@@ -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.
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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、ACPAgent 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 Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
+5 -6
View File
@@ -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<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
/**