소스 검색

fix(boot): harden the package probe's child

The probe child now runs with the harness's credential-shaped variables
scrubbed — the one rule every spawner uses, now defined in
dsh-launch-environment and re-exported by dsh-subprocess — and its report is
the one message echoing a per-run token the child removes from its environment,
together with process.send, before importing anything. The failure text keeps
the last 16 KiB of stderr instead of every byte, and the probe settles only on
the child's close, so no pipe or channel outlives the call.
Yichen Jiang 1 개월 전
부모
커밋
6bc5e978ae

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.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 .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
-2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c0ae61e1323de3dcbeec826363dc953c32dbdcc5
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: d4e136e36dcf90ad78b499123bfe56add868484d
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 940b500f9f0b5c531e0c0eaf849a8a8000a90a6a
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 2205a1abfba92c06b3e65745a9d79bb09421bdb2

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md

@@ -16,7 +16,7 @@ Separately, nothing could say what an installed package was without importing it
 
 **Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
 
-**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: an unrecognized report is a rejection, an unrecognized record is probed again. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
+**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: a message that does not echo the run's token is the package's own, not the report, and an unrecognized record is probed again. The child gets the parent environment minus credential-shaped names, removes the token and `process.send` before importing, keeps at most 16 KiB of stderr for the failure text, and is awaited to `close` after the kill so nothing of it outlives the call. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 **嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。
 
-**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:无法识别的报告是一次 rejection,无法识别的记录重新探测。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
+**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:没有回显本次 token 的消息是包自己的、不算报告,无法识别的记录重新探测。子进程拿到的是剔除了密钥形态变量的父环境,import 之前先删掉 token 与 `process.send`,stderr 只留最后 16 KiB 作失败文本,kill 之后等到 `close` 才结算,所以子进程的任何东西都不会活过这次调用。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
 
 ## 考虑过的替代方案
 

+ 2 - 2
packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: 7755642ba4afec550c599971375c2149f8ed6cae
-README.zh.md: 3c89f4849e94b4ceedc82015e7386557f6792349
+README.md: 2da207fb7a04db7fc73eb1b04a921d3e2ddd10dc
+README.zh.md: 217881ccf67bb3213eaff4cd442ff386d5897304

+ 1 - 1
packages/boot/app-boot/README.md

@@ -93,7 +93,7 @@ This section explains how the outcomes above are realized and points at the code
 - **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that updates drops the records of rows it no longer configures and one that unmounts drops them all, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
 - **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. A config override may restate a row under the group that already holds it; the same id twice in one config list, or set under another group, counts as declared twice — the Loader would reject the first at mount and silently move the second — so a built-in layer fails the boot and a contained bundle is left out. Boot, live recomposition, and `--dump-config` compose through the same function.
 - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
-- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
+- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. The child runs with the harness's credential-shaped variables scrubbed (`SENSITIVE_ENV_PATTERN` from `dsh-launch-environment`), its report is the one message that echoes a per-run token it removed from its environment before importing — the imported code finds no `process.send` either — the failure text keeps the last 16 KiB of its stderr, and the probe settles only once the child closed. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -93,7 +93,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。config 覆盖可以在已持有某行的组下重述它;同一 config 列表里出现两次、或改放到别的组下,都算声明了两次——前者会在挂载时被 Loader 拒绝,后者会被静默挪走——因此内置层启动失败、受控组合包整层排除。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
-- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
+- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`dsh-launch-environment` 的 `SENSITIVE_ENV_PATTERN`),报告是唯一回显本次 token 的那条消息,token 在 import 之前就从子进程环境里删掉、被 import 的代码也找不到 `process.send`;失败文本只留 stderr 的最后 16 KiB;探针要等子进程关闭后才结算。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。

+ 11 - 3
packages/boot/app-boot/src/probe-child.ts

@@ -4,7 +4,8 @@
  * over the IPC channel the parent opened. stdout and stderr stay the
  * imported modules' own, so a package that prints at import still reports.
  * Arguments: the package directory, the main specifier (empty for none), and
- * the addable specifiers as a JSON array. Nothing here runs inside the host.
+ * the addable specifiers as a JSON array; the environment carries the token
+ * the report echoes. Nothing here runs inside the host.
  * @module @deepseek-ai/dsh-app-boot/probe-child
  */
 
@@ -13,6 +14,13 @@ import type { ChildInspection, ChildReport } from './probe-report.ts'
 
 const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2)
 const base = pathToFileURL(`${dir}/package.json`).href
+// The report is the parent's to receive: its token leaves the environment and
+// the channel's `send` leaves `process` before any of the package's code runs,
+// so nothing that code sends at import can pass as the report.
+const token = process.env.DSH_PROBE_REPORT ?? ''
+delete process.env.DSH_PROBE_REPORT
+const send = process.send?.bind(process)
+Reflect.deleteProperty(process, 'send')
 
 /** Import one module from the package and describe what it exports. */
 async function inspect(specifier: string): Promise<ChildInspection> {
@@ -31,7 +39,7 @@ async function inspect(specifier: string): Promise<ChildInspection> {
   }
 }
 
-const report: ChildReport = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
+const report: ChildReport = { token, cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
 try {
   report.cordis = import.meta.resolve('@deepseek-ai/cordis', base)
 } catch {
@@ -39,4 +47,4 @@ try {
 }
 if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
 for (const name of JSON.parse(addableJson) as string[]) report.addable[name] = await inspect(name)
-process.send?.(report, undefined, undefined, () => { process.disconnect() })
+send?.(report, undefined, undefined, () => { process.disconnect() })

+ 3 - 0
packages/boot/app-boot/src/probe-report.ts

@@ -20,6 +20,8 @@ export interface ChildInspection {
 
 /** The child's one message: where cordis resolves from the package, and what each module imported as. */
 export interface ChildReport {
+  /** The token the parent handed the child for this run; a message without it is not the report. */
+  token: string
   /** The URL the package resolves `@deepseek-ai/cordis` to, or null when it does not resolve it. */
   cordis: string | null
   /** The main export's inspection; the not-imported default when the package declares no main. */
@@ -53,6 +55,7 @@ function isInspection(value: unknown): value is ChildInspection {
  */
 export function parseChildReport(value: unknown): ChildReport | undefined {
   if (!isRecord(value)) return undefined
+  if (typeof value.token !== 'string') return undefined
   if (value.cordis !== null && typeof value.cordis !== 'string') return undefined
   if (!isInspection(value.main) || !isRecord(value.addable)) return undefined
   if (!Object.values(value.addable).every(isInspection)) return undefined

+ 55 - 25
packages/boot/app-boot/src/probe.ts

@@ -10,9 +10,11 @@
  */
 
 import { spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { withoutSensitiveEnv } from '@deepseek-ai/dsh-launch-environment'
 import { loadOverlayPatches } from './index.ts'
 import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
 import { visitPatchRows } from './patch-rows.ts'
@@ -136,54 +138,82 @@ function childEntryArgs(): string[] {
  * the probe needs, so the child is killed once it arrived: a package that
  * keeps a timer alive after import costs nothing more. stdout is not read
  * at all, so whatever the imported modules print cannot corrupt the report.
+ * The child gets the parent environment minus credential-shaped names, plus
+ * a per-run token it echoes in its report — a message without the token is
+ * the package's own, not the report — and the probe settles only once the
+ * child closed, so its pipes and channel are gone when the caller continues.
  */
 function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
   const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
   return new Promise((resolve, reject) => {
+    const token = randomUUID()
     const child = spawn(
       options.nodeExecutable ?? process.execPath,
       ['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)],
-      { cwd: options.profileDir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
+      {
+        cwd: options.profileDir,
+        stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
+        env: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token },
+      },
     )
+    // The tail of stderr, for the failure text: a package that floods stderr at
+    // import must not grow this process's heap by as much.
     const err: Buffer[] = []
-    child.stderr?.on('data', (chunk: Buffer) => err.push(chunk))
-    // One settlement: a spawn failure emits `error` and then `close`, a
-    // timeout kill emits `close` after the rejection below, and the kill
-    // after a report emits `close` after the resolution.
-    let settled = false
-    let unrecognized = false
-    const settle = (outcome: () => void): void => {
-      if (settled) return
-      settled = true
+    let errBytes = 0
+    child.stderr?.on('data', (chunk: Buffer) => {
+      err.push(chunk)
+      errBytes += chunk.length
+      while (errBytes > STDERR_TAIL_BYTES && err.length > 1) errBytes -= (err.shift() as Buffer).length
+      if (errBytes > STDERR_TAIL_BYTES) {
+        err[0] = (err[0] as Buffer).subarray(errBytes - STDERR_TAIL_BYTES)
+        errBytes = STDERR_TAIL_BYTES
+      }
+    })
+    // The outcome lands on `close`. A kill — after the report, or at the
+    // timeout — records its outcome and waits for the close it causes, so the
+    // child's pipes and channel are gone when the caller continues; a spawn
+    // failure emits `error` with no process to wait for.
+    let outcome: (() => void) | undefined
+    const finish = (next: () => void): void => {
+      /* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */
+      if (outcome !== undefined) return
+      outcome = next
       clearTimeout(timer)
-      outcome()
+      child.kill('SIGKILL')
     }
     const timer = setTimeout(() => {
-      child.kill('SIGKILL')
-      settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
+      finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
     }, timeoutMs)
     child.on('message', (message) => {
       const report = parseChildReport(message)
-      if (report === undefined) {
-        unrecognized = true
-        return
-      }
-      child.kill('SIGKILL')
-      settle(() => { resolve(report) })
+      /* v8 ignore next -- the imported code finds no process.send; a message through the raw channel would still lack the token */
+      if (report === undefined || report.token !== token) return
+      finish(() => { resolve(report) })
+    })
+    child.on('error', (error) => {
+      clearTimeout(timer)
+      reject(error)
     })
-    child.on('error', (error) => { settle(() => { reject(error) }) })
     child.on('close', (code) => {
-      settle(() => {
-        reject(new Error(unrecognized
-          ? `${options.binName}: probe of ${options.packageName} reported an unrecognized value`
-          : `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`))
-      })
+      clearTimeout(timer)
+      if (outcome !== undefined) {
+        outcome()
+        return
+      }
+      const tail = Buffer.concat(err).toString('utf8').trim()
+      reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`))
     })
   })
 }
 
 const DEFAULT_TIMEOUT_MS = 20_000
 
+/** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */
+const STDERR_TAIL_BYTES = 16 * 1024
+
+/** The environment name carrying the run's report token; the child removes it before importing anything. */
+const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT'
+
 /** The rows a bundle patch introduces, flattened from nested groups, with the ids of overrides on other rows. */
 function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
   const rows: PluginProbeRow[] = []

+ 37 - 4
packages/boot/app-boot/tests/probe.spec.ts

@@ -175,6 +175,38 @@ describe('probePackage', () => {
     expect(chatty).toMatchObject({ kind: 'plugin', ok: true })
     const lingers = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'lingers', timeoutMs: 5_000 })
     expect(lingers).toMatchObject({ kind: 'plugin', ok: true })
+    // The probe settled on the child's close: nothing of the killed child is still open.
+    expect(process.getActiveResourcesInfo()).not.toContain('ChildProcess')
+  })
+
+  it('takes only the message that echoes its token, hides credentials from the child, and keeps a stderr tail', async () => {
+    const { profileDir, installAnchor } = stage({
+      // A report forged at import, complete with the token's name: the token is
+      // gone from the environment and `process.send` from `process` by then.
+      'forges': {
+        main: 'process.send?.({ token: process.env.DSH_PROBE_REPORT ?? "", cordis: null, main: { ok: true, isPlugin: true, configSchema: null }, addable: {} })\n'
+          + 'export const notAPlugin = 1\n',
+        manifest: { dsh: { title: 'Forges' } },
+      },
+      'peeks': {
+        main: 'throw new Error("env=" + Object.keys(process.env).filter(k => k.startsWith("PROBE_TEST") || k === "DSH_PROBE_REPORT").sort().join(","))\n',
+      },
+      'floods': { main: 'import { writeSync } from "node:fs"\nwriteSync(2, "x".repeat(200_000))\nwriteSync(2, "tail-marker")\nprocess.exit(3)\n' },
+    })
+    const forged = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'forges' })
+    expect(forged).toMatchObject({ kind: 'library', ok: true })
+    process.env.PROBE_TEST_SECRET = 'hidden'
+    process.env.PROBE_TEST_PLAIN = 'visible'
+    try {
+      const peeked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'peeks' })
+      expect(peeked.reason).toContain('env=PROBE_TEST_PLAIN')
+    } finally {
+      delete process.env.PROBE_TEST_SECRET
+      delete process.env.PROBE_TEST_PLAIN
+    }
+    const flood = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'floods' }).then(() => undefined, (error: unknown) => error as Error)
+    expect(flood?.message).toMatch(/exited with 3 without a report: x+tail-marker$/)
+    expect(flood?.message.length).toBeLessThan(17_000)
   })
 
   it('kills a child that never reports, rejects an unrecognized report, and refuses an unresolvable package', async () => {
@@ -182,14 +214,15 @@ describe('probePackage', () => {
       // Blocks the child's thread inside the import: an unsettled top-level await would make Node exit instead.
       'hangs': { main: 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0)\nexport function apply() {}\n' },
       'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' },
-      'spoofs': { main: 'process.send({ nope: true }); process.exit(0)\n' },
+      // `process.send` is gone by the time the package runs; a message it could send would not carry the token anyway.
+      'spoofs': { main: 'process.send?.({ nope: true }); process.exit(0)\n' },
     })
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 }))
       .rejects.toThrow(/timed out after 300ms/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' }))
       .rejects.toThrow(/exited with 3 without a report: refusing to report/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'spoofs' }))
-      .rejects.toThrow(/reported an unrecognized value/)
+      .rejects.toThrow(/exited with 0 without a report/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' }))
       .rejects.toThrow(/cannot resolve profile bundle "ghost"/)
     await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' }))
@@ -248,11 +281,11 @@ describe('probe cache', () => {
 describe('parseChildReport', () => {
   it('accepts the child\'s message only with every field in place', () => {
     const inspection = { ok: true, isPlugin: true, configSchema: null }
-    const report: ChildReport = { cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } }
+    const report: ChildReport = { token: 't', cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } }
     expect(parseChildReport(report)).toBe(report)
     expect(parseChildReport({ ...report, cordis: 'file:///cordis/index.js' })).toBeDefined()
     const broken: Record<string, unknown>[] = [
-      { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
+      { token: undefined }, { token: 1 }, { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
       { main: { ok: true, isPlugin: true } }, { main: { ...inspection, error: 1 } }, { addable: [] }, { addable: { 'pkg/x': 1 } },
     ]
     for (const fields of broken) expect(parseChildReport({ ...report, ...fields }), JSON.stringify(fields)).toBeUndefined()

+ 2 - 1
packages/subprocess/subprocess/package.json

@@ -28,7 +28,8 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-http-proxy": "workspace:^"
+    "@deepseek-ai/dsh-http-proxy": "workspace:^",
+    "@deepseek-ai/dsh-launch-environment": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 4 - 2
packages/subprocess/subprocess/src/index.ts

@@ -10,6 +10,7 @@
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
+import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-launch-environment'
 import { DSH_ENV_PREFIX } from './types.ts'
 import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
@@ -38,11 +39,12 @@ export type {
 /**
  * Credential-shaped environment names are NOT forwarded to children (the
  * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
- * process implicitly). One heuristic for every in-repo spawner; a
+ * process implicitly). One heuristic for every in-repo spawner, defined in
+ * `dsh-launch-environment` so the package probe scrubs by the same rule; a
  * deliberately supplied entry survives because explicit env layers merge
  * after the scrub.
  */
-export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
+export { SENSITIVE_ENV_PATTERN }
 
 /**
  * The ambient parent environment minus credential-shaped names and minus all

+ 2 - 2
packages/util/launch-environment/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/util/launch-environment/README.md
-README.md: 312952f527af02c9bd9b19d3560d65c84b446b7d
-README.zh.md: 3071052c941737b365d1557ead005ede5ba857d3
+README.md: 0bb0bba6f5b29cc88f5919ee70fba89b28eb0cb8
+README.zh.md: d5707d7a35022797683c0300031de3acdde2f3c2

+ 4 - 0
packages/util/launch-environment/README.md

@@ -51,6 +51,10 @@ Names match the way the platform matches them: exactly on POSIX, case-insensitiv
 
 `launchEnvironmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. The fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has is the environment it was launched with.
 
+### Scrubbing credentials for a child
+
+`withoutSensitiveEnv(process.env)` returns the environment without its credential-shaped entries — names matching `SENSITIVE_ENV_PATTERN` (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`, case-insensitively) — and without unset values, for a child that must not see the harness's credentials: the package probe in `dsh-app-boot` spawns with it, and `dsh-subprocess` scrubs every spawned command by the same pattern.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 4 - 0
packages/util/launch-environment/README.zh.md

@@ -51,6 +51,10 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
 
 当产品 CLI 引导了这棵树时,`launchEnvironmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切就是它被启动时的环境。
 
+### 为子进程剔除凭据
+
+`withoutSensitiveEnv(process.env)` 返回去掉了密钥形态条目——名字匹配 `SENSITIVE_ENV_PATTERN`(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`,不分大小写)——且去掉了未设置值的环境,给不该看到 harness 凭据的子进程用:`dsh-app-boot` 的包探针用它生成子进程,`dsh-subprocess` 按同一模式清洗每条被生成的命令。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 21 - 0
packages/util/launch-environment/src/index.ts

@@ -122,3 +122,24 @@ declare module '@deepseek-ai/cordis' {
     launchEnvironment?: LaunchEnvironmentSnapshot
   }
 }
+
+/**
+ * Environment names that carry credentials — API keys, passwords, secrets,
+ * tokens — matched case-insensitively. The one rule every harness child spawn
+ * scrubs by, so the harness's own credentials never reach a child implicitly.
+ */
+export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
+
+/**
+ * An environment without its credential-shaped entries, for a child that must
+ * not see the harness's credentials: a package probe, a spawned tool.
+ * @param env - the environment to scrub, typically `process.env`.
+ * @returns a fresh object holding every entry whose name is not credential-shaped and whose value is set.
+ */
+export function withoutSensitiveEnv(env: NodeJS.ProcessEnv): Record<string, string> {
+  const scrubbed: Record<string, string> = {}
+  for (const [name, value] of Object.entries(env)) {
+    if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(name)) scrubbed[name] = value
+  }
+  return scrubbed
+}

+ 10 - 1
packages/util/launch-environment/tests/launch-environment.spec.ts

@@ -1,9 +1,18 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import {
-  createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf,
+  createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf, SENSITIVE_ENV_PATTERN, withoutSensitiveEnv,
 } from '../src/index.ts'
 
+describe('withoutSensitiveEnv', () => {
+  it('drops credential-shaped names, case-insensitively, and unset values', () => {
+    expect(withoutSensitiveEnv({
+      PATH: '/bin', DEEPSEEK_API_KEY: 'k', npm_config_token: 't', Secret_Thing: 's', DB_PASSWORD: 'p', UNSET: undefined,
+    })).toEqual({ PATH: '/bin' })
+    expect(SENSITIVE_ENV_PATTERN.test('HOME')).toBe(false)
+  })
+})
+
 const layered = createLaunchEnvironmentSnapshot([
   { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
   { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },