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:
@@ -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
|
||||
README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3
|
||||
README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6
|
||||
README.md: 65c2be2b77fbd7105208b86fa1b98a3c44013f94
|
||||
README.zh.md: 98a0315914746a94350ceec53e4daff886054e64
|
||||
|
||||
@@ -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`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification).
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). 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 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.
|
||||
- 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).
|
||||
|
||||
@@ -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`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,19 +93,14 @@ export interface SubprocessSpawnSpec {
|
||||
*/
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Ordinary environment entries merged onto the implementation's scrubbed
|
||||
* parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and
|
||||
* belong in {@link dshEnv}; a deliberately forwarded credential-shaped
|
||||
* entry survives because this layer merges after the scrub.
|
||||
* 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.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Harness-owned `DSH_*` variables for this execution. The scrubbed base has
|
||||
* already discarded ambient `DSH_*` entries, so an unavailable current fact
|
||||
* cannot inherit a stale value from the harness process; non-`DSH_*` names
|
||||
* on this channel are rejected.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
@@ -72,10 +72,4 @@ describe('SubprocessService seam', () => {
|
||||
delete process.env.SCRUB_PROBE_PLAIN
|
||||
}
|
||||
})
|
||||
|
||||
it('splitEnvChannels partitions a mixed map onto the two spec channels', () => {
|
||||
const { env, dshEnv } = splitEnvChannels({ DSH_FACT: 'managed', PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' })
|
||||
expect(env).toEqual({ PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' })
|
||||
expect(dshEnv).toEqual({ DSH_FACT: 'managed' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user