Преглед на файлове

fix(boot): report the package probe over IPC and validate what crosses the boundary

The probe's child was a script string that wrote its report to stdout, and
the parent parsed the whole stream as one JSON value: a plugin that merely
printed a line at import could not be probed, and a package that kept a
timer alive after import ran until the timeout. The parent asserted the
parsed value as the report, and the cache read checked only `format`, so a
file holding nothing but `{ "format": 2 }` came back as a complete record.

The child is now its own module, `probe-child.ts`, run through tsx under a
source launch and bundled as `lib/probe-child.js`, and it sends one report
over an IPC channel; stdout is not read at all, and the child is killed as
soon as the report arrived. `parseChildReport` and `parseProbeRecord`
validate the report and the cached record field by field, so an
unrecognized report is a rejection and an unrecognized record is probed
again. `PluginProbe.ok` now states what it is: the main export imported and
cordis is not a second copy; `kind` and `addable[].ok` decide what can be
enabled or added.
Yichen Jiang преди 1 месец
родител
ревизия
0f95351405

+ 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: b97bfb57ccda65aa69832db4a9772e03853328a6
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 0dffeb0699d8fa847110f33e1579dd4ab1227767
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 0d50be413af8ed5622dd7289f86a4026b0c69f45
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 8b8c574941c616419958328bec70179fba5a5331

+ 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 that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and reports whether each is a plugin and what `Config.toJSON()` it carries. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; a package whose cordis resolves elsewhere than the harness's own is not enableable. 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: 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.
 
 ## 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 子进程:从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,报告各自是否为插件以及携带的 `Config.toJSON()`。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;cordis 解析到 harness 自有副本之外的包不可启用。记录缓存在 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 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:无法识别的报告是一次 rejection,无法识别的记录重新探测。抛错、退出或挂起的子进程得到带原因的 `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: 881b8d1acc3197a218e6e6dca1c4c6be4fb78e07
-README.zh.md: faffc8acb38c0b3510fec75be22af36d7776f2f6
+README.md: f162e1e189c9366c0c4ce3e7aeac4b1ea736dcb1
+README.zh.md: 21cab2fbe661ca44bf16aa326a48951f49976229

+ 2 - 2
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 unmounts drops its rows' records, 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`. 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, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. 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. 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.
@@ -112,7 +112,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | [`src/compose-stack.ts`](src/compose-stack.ts) | Row-id ownership across the stack: `claimLayerIds`, `composeProfileStack`, conflict records |
 | [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: the committed composition (profile, row provenance, conflicts), user-disabled rows, recomposition |
-| [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache |
+| [`src/probe.ts`](src/probe.ts) | The package probe and its per-profile cache; [`src/probe-child.ts`](src/probe-child.ts) is the child entry it spawns and [`src/probe-report.ts`](src/probe-report.ts) the report it validates |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 
 </details>

+ 2 - 2
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` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
-- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
+- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 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`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
@@ -112,7 +112,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
 | [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:已提交的组合(profile、行来源、冲突)、用户停用的行、重新组合 |
-| [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 |
+| [`src/probe.ts`](src/probe.ts) | 包探针及其按 profile 的缓存;[`src/probe-child.ts`](src/probe-child.ts) 是它生成的子进程入口,[`src/probe-report.ts`](src/probe-report.ts) 是它校验的报告 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 </details>

+ 1 - 0
packages/boot/app-boot/package.json

@@ -23,6 +23,7 @@
   },
   "files": [
     "lib/index.js",
+    "lib/probe-child.js",
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",

+ 42 - 0
packages/boot/app-boot/src/probe-child.ts

@@ -0,0 +1,42 @@
+/**
+ * The probe's child process: resolve cordis from the probed package, import
+ * its main export and every declared addable module, and send one report
+ * 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.
+ * @module @deepseek-ai/dsh-app-boot/probe-child
+ */
+
+import { pathToFileURL } from 'node:url'
+import type { ChildInspection, ChildReport } from './probe-report.ts'
+
+const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2)
+const base = pathToFileURL(`${dir}/package.json`).href
+
+/** Import one module from the package and describe what it exports. */
+async function inspect(specifier: string): Promise<ChildInspection> {
+  try {
+    const mod = await import(import.meta.resolve(specifier, base)) as Record<string, unknown>
+    const plugin = mod.default ?? mod
+    const schema = (plugin as { Config?: unknown }).Config ?? mod.Config
+    const toJSON = (schema as { toJSON?: unknown } | null | undefined)?.toJSON
+    return {
+      ok: true,
+      isPlugin: typeof plugin === 'function' || typeof (plugin as { apply?: unknown }).apply === 'function',
+      configSchema: typeof toJSON === 'function' ? (toJSON as () => unknown).call(schema) : null,
+    }
+  } catch (error) {
+    return { ok: false, isPlugin: false, configSchema: null, error: String((error as { stack?: unknown } | null)?.stack ?? error) }
+  }
+}
+
+const report: ChildReport = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
+try {
+  report.cordis = import.meta.resolve('@deepseek-ai/cordis', base)
+} catch {
+  report.cordis = null // the package resolves no cordis at all: a library, or a plugin without the peer installed
+}
+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() })

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

@@ -0,0 +1,60 @@
+/**
+ * What the probe's child process reports, and the check the parent runs on
+ * it. The report crosses a process boundary as an IPC message, so the parent
+ * validates every field before trusting it; the child only imports the
+ * types.
+ * @module @deepseek-ai/dsh-app-boot/probe-report
+ */
+
+/** What importing one module in the child found. */
+export interface ChildInspection {
+  /** Whether the import succeeded. */
+  ok: boolean
+  /** Whether the module's default or namespace export is a cordis plugin. */
+  isPlugin: boolean
+  /** The plugin's `Config.toJSON()`, or null when it declares none. */
+  configSchema: unknown
+  /** The import failure, when `ok` is false. */
+  error?: string
+}
+
+/** The child's one message: where cordis resolves from the package, and what each module imported as. */
+export interface ChildReport {
+  /** 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. */
+  main: ChildInspection
+  /** The inspection of each declared addable module, by its specifier. */
+  addable: Record<string, ChildInspection>
+}
+
+/**
+ * Whether a value is a plain object: the only JSON value with named fields.
+ * @param value - the value to test.
+ * @returns true for a non-null, non-array object.
+ */
+export function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Whether a value has the fields of one inspection. */
+function isInspection(value: unknown): value is ChildInspection {
+  return isRecord(value)
+    && typeof value.ok === 'boolean'
+    && typeof value.isPlugin === 'boolean'
+    && 'configSchema' in value
+    && (value.error === undefined || typeof value.error === 'string')
+}
+
+/**
+ * Validate a message the child sent as its report.
+ * @param value - the message as received.
+ * @returns the report, or undefined when the message is not one.
+ */
+export function parseChildReport(value: unknown): ChildReport | undefined {
+  if (!isRecord(value)) 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
+  return value as unknown as ChildReport
+}

+ 88 - 58
packages/boot/app-boot/src/probe.ts

@@ -2,17 +2,21 @@
  * The install-time probe: what an installed package is and whether this
  * harness can load it, answered in a child process so a package that throws,
  * hangs, or brings its own copy of cordis never runs inside the host. The
- * manifest facts (kind, rows, declared modules) are read here; the child only
- * imports.
+ * manifest facts (kind, rows, declared modules) are read here; the child
+ * (`probe-child.ts`) only imports and reports over IPC, and the report and
+ * the cached record are validated as the process and file boundaries they
+ * cross.
  * @module @deepseek-ai/dsh-app-boot/probe
  */
 
 import { spawn } from 'node:child_process'
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { loadOverlayPatches } from './index.ts'
 import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
 import { visitInsertedRows } from './patch-rows.ts'
+import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
 
 /** Directory under a profile holding one probe record per package. */
 export const PLUGIN_PROBE_DIR = '.dsh-plugins'
@@ -67,9 +71,14 @@ export interface PluginProbe {
    * not a plugin: `lodash` exports one too.
    */
   readonly kind: 'bundle' | 'plugin' | 'library'
-  /** Whether the package can be enabled or added: it imported and shares the harness's cordis. */
+  /**
+   * Whether the main export imported and cordis is not a second copy. True
+   * for a `library`, for a package whose cordis resolution is unknown, and
+   * for one whose addable modules failed: `kind` and `addable[].ok` decide
+   * what can be enabled or added.
+   */
   readonly ok: boolean
-  /** Why it cannot, when `ok` is false. */
+  /** Why the import or the cordis check failed, when `ok` is false. */
   readonly reason?: string
   /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
   readonly cordisSameCopy: boolean | null
@@ -110,59 +119,39 @@ interface ProbedManifest extends ProfileManifest {
   engines?: Record<string, string>
 }
 
-/** What the child process reports. */
-interface ChildReport {
-  cordis: string | null
-  main: { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }
-  addable: Record<string, { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }>
-}
-
 /**
- * The script the child runs: resolve cordis from the package, import the main
- * export and every declared addable module, and report. Parameters arrive
- * as argv so no value is interpolated into code.
+ * The child entry beside this module: the TypeScript source under a source
+ * launch, run through tsx; the bundled `lib/probe-child.js` otherwise.
  */
-const CHILD_SCRIPT = `
-import { pathToFileURL } from 'node:url'
-const [dir, mainSpecifier, addableJson] = process.argv.slice(1)
-const base = pathToFileURL(dir + '/package.json').href
-const report = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
-try { report.cordis = import.meta.resolve('@deepseek-ai/cordis', base) } catch { report.cordis = null }
-const inspect = async (specifier) => {
-  try {
-    const mod = await import(import.meta.resolve(specifier, base))
-    const plugin = mod.default ?? mod
-    const schema = plugin?.Config ?? mod.Config
-    return {
-      ok: true,
-      isPlugin: typeof plugin === 'function' || typeof plugin?.apply === 'function',
-      configSchema: typeof schema?.toJSON === 'function' ? schema.toJSON() : null,
-    }
-  } catch (error) {
-    return { ok: false, isPlugin: false, configSchema: null, error: String(error?.stack ?? error) }
+function childEntryArgs(): string[] {
+  /* v8 ignore next 3 -- the built-output arm: tests run from src */
+  if (!import.meta.url.endsWith('.ts')) {
+    return [fileURLToPath(new URL('./probe-child.js', import.meta.url))]
   }
+  return ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./probe-child.ts', import.meta.url))]
 }
-if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
-for (const name of JSON.parse(addableJson)) report.addable[name] = await inspect(name)
-process.stdout.write(JSON.stringify(report))
-`
 
-/** Run the child and parse its report. */
+/**
+ * Run the child and take its report from the IPC channel. The report is all
+ * 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.
+ */
 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 child = spawn(
       options.nodeExecutable ?? process.execPath,
-      ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
-      { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
+      ['--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' } },
     )
-    const out: Buffer[] = []
     const err: Buffer[] = []
-    child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
-    child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
-    // One settlement: a spawn failure emits `error` and then `close`, and a
-    // timeout kill emits `close` after the rejection below.
+    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
@@ -173,17 +162,21 @@ function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: stri
       child.kill('SIGKILL')
       settle(() => { 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) })
+    })
     child.on('error', (error) => { settle(() => { reject(error) }) })
     child.on('close', (code) => {
       settle(() => {
-        const stdout = Buffer.concat(out).toString('utf8')
-        try {
-          resolve(JSON.parse(stdout) as ChildReport)
-        } catch {
-          reject(new Error(
-            `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
-          ))
-        }
+        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()}`))
       })
     })
   })
@@ -307,23 +300,60 @@ function probeCachePath(profileDir: string, packageName: string): string {
  * @param profileDir - the profile directory.
  * @param packageName - the package.
  * @param version - when given, a record for a different version is treated as absent.
- * @returns the record, or undefined when none is cached or the cached one was written by another probe format.
+ * @returns the record, or undefined when none is cached, the cached one was written by another probe format, or it is not a record.
  */
 export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
   const path = probeCachePath(profileDir, packageName)
   if (!existsSync(path)) return undefined
-  let stored: PluginProbe & { format?: number }
+  let stored: unknown
   try {
-    stored = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe & { format?: number }
+    stored = JSON.parse(readFileSync(path, 'utf8'))
   } catch {
-    return undefined
+    return undefined // not JSON: a truncated or hand-edited file is probed again
   }
-  const { format, ...record } = stored
-  if (format !== PLUGIN_PROBE_FORMAT) return undefined
+  if (!isRecord(stored) || stored.format !== PLUGIN_PROBE_FORMAT) return undefined
+  const { format: _format, ...fields } = stored
+  const record = parseProbeRecord(fields)
+  if (record === undefined) return undefined
   if (version !== undefined && record.version !== version) return undefined
   return record
 }
 
+const PROBE_KINDS: ReadonlySet<string> = new Set<PluginProbe['kind']>(['bundle', 'plugin', 'library'])
+
+/** Whether a value is absent or a string. */
+function optionalString(value: unknown): boolean {
+  return value === undefined || typeof value === 'string'
+}
+
+/** Whether a value has the fields of one probed row. */
+function isProbeRow(value: unknown): value is PluginProbeRow {
+  return isRecord(value) && optionalString(value.id) && typeof value.name === 'string' && typeof value.gated === 'boolean'
+}
+
+/** Whether a value has the fields of one addable module. */
+function isProbeAddable(value: unknown): value is PluginProbeAddable {
+  return isRecord(value) && typeof value.name === 'string' && optionalString(value.title) && typeof value.ok === 'boolean' && optionalString(value.error)
+}
+
+/**
+ * Validate a stored probe record, as the cache file is a boundary this
+ * process does not control.
+ * @param value - the parsed file without its `format` field.
+ * @returns the record, or undefined when a field is missing or mistyped.
+ */
+export function parseProbeRecord(value: unknown): PluginProbe | undefined {
+  if (!isRecord(value)) return undefined
+  if (typeof value.packageName !== 'string' || typeof value.checkedAt !== 'string') return undefined
+  if (![value.version, value.description, value.title, value.reason, value.enginesDsh].every(optionalString)) return undefined
+  if (typeof value.kind !== 'string' || !PROBE_KINDS.has(value.kind) || typeof value.ok !== 'boolean') return undefined
+  if (value.cordisSameCopy !== null && typeof value.cordisSameCopy !== 'boolean') return undefined
+  if (!Array.isArray(value.rows) || !value.rows.every(isProbeRow)) return undefined
+  if (!Array.isArray(value.overrides) || !value.overrides.every(item => typeof item === 'string')) return undefined
+  if (!Array.isArray(value.addable) || !value.addable.every(isProbeAddable)) return undefined
+  return value as unknown as PluginProbe
+}
+
 /**
  * Persist one package's probe record under the profile.
  * @param profileDir - the profile directory.

+ 60 - 2
packages/boot/app-boot/tests/probe.spec.ts

@@ -8,6 +8,8 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { describe, expect, it } from 'vitest'
 import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
+import { parseProbeRecord } from '../src/probe.ts'
+import { parseChildReport, type ChildReport } from '../src/probe-report.ts'
 
 const NAME = 'dsh-test-bin'
 
@@ -164,15 +166,30 @@ describe('probePackage', () => {
     expect(probe.addable[0]?.error).toBeDefined()
   })
 
-  it('kills a child that never reports and refuses an unresolvable package', async () => {
+  it('keeps probing a package that prints at import, and kills one that lingers once it reported', async () => {
     const { profileDir, installAnchor } = stage({
-      'hangs': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n' },
+      'chatty': { main: 'console.log("initializing logging-plugin")\nexport function apply() {}\n', manifest: { dsh: { title: 'Chatty' } } },
+      'lingers': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n', manifest: { dsh: { title: 'Lingers' } } },
+    })
+    const chatty = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'chatty' })
+    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 })
+  })
+
+  it('kills a child that never reports, rejects an unrecognized report, and refuses an unresolvable package', async () => {
+    const { profileDir, installAnchor } = stage({
+      // 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' },
     })
     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/)
     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' }))
@@ -199,5 +216,46 @@ describe('probe cache', () => {
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
     writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    // A file with the right format but not the fields of a record is probed again, not returned as one.
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT }))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...record, kind: 'weird' }))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify([record]))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+  })
+
+  it('accepts only a complete record', () => {
+    const record: PluginProbe = {
+      packageName: 'pkg', version: '1.0.0', description: 'd', title: 't', kind: 'bundle', ok: false, reason: 'r',
+      cordisSameCopy: false, enginesDsh: '>=1', rows: [{ id: 'a', name: 'm', gated: false }, { name: 'n', gated: true }],
+      overrides: ['x'], addable: [{ name: 'p', title: 'u', ok: false, error: 'e' }, { name: 'q', ok: true }],
+      configSchema: { type: 'object' }, checkedAt: '2026-09-05T00:00:00.000Z',
+    }
+    expect(parseProbeRecord(record)).toBe(record)
+    const broken: Record<string, unknown>[] = [
+      { packageName: 1 }, { checkedAt: 1 }, { version: 1 }, { kind: 1 }, { kind: 'weird' }, { ok: 'yes' },
+      { cordisSameCopy: 'no' }, { rows: {} }, { rows: [1] }, { rows: [{ id: 1, name: 'm', gated: false }] },
+      { rows: [{ name: 1, gated: false }] }, { rows: [{ name: 'm', gated: 'no' }] }, { overrides: 'x' }, { overrides: [1] },
+      { addable: {} }, { addable: [1] }, { addable: [{ name: 1, ok: true }] }, { addable: [{ name: 'p', title: 1, ok: true }] },
+      { addable: [{ name: 'p', ok: 'yes' }] }, { addable: [{ name: 'p', ok: true, error: 1 }] },
+    ]
+    for (const fields of broken) expect(parseProbeRecord({ ...record, ...fields }), JSON.stringify(fields)).toBeUndefined()
+    expect(parseProbeRecord('record')).toBeUndefined()
+  })
+})
+
+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' } } }
+    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' } },
+      { 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()
+    expect(parseChildReport('report')).toBeUndefined()
   })
 })

+ 26 - 12
packages/boot/app-boot/tsdown.config.ts

@@ -4,16 +4,30 @@ import { defineConfig } from 'tsdown'
  * Embed Include while keeping Loader external so the built include tree and
  * app host bind to one Loader peer.
  */
-export default defineConfig({
-  entry: ['lib/types/index.js'],
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'node',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-  deps: {
-    alwaysBundle: ['@deepseek-ai/cordis-plugin-include'],
+export default defineConfig([
+  {
+    entry: ['lib/types/index.js'],
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'node',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+    deps: {
+      alwaysBundle: ['@deepseek-ai/cordis-plugin-include'],
+    },
   },
-})
+  {
+    // The package probe's child entry ships beside the lib as its own
+    // bundle; the probe path-loads it relative to its own module.
+    entry: { 'probe-child': 'lib/types/probe-child.js' },
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'node',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+  },
+])

+ 2 - 0
scripts/check-workspace-constraints.ts

@@ -166,6 +166,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // resolve at install time, before the build produces lib/bin.js.
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
   '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
+  // The package probe's child process entry, path-loaded beside the lib.
+  '@deepseek-ai/dsh-app-boot': ['lib/probe-child.js'],
 }
 
 function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {

+ 2 - 0
vitest.config.ts

@@ -210,6 +210,8 @@ export default defineConfig({
         'packages/*/*/src/types.ts',
         'packages/*/*/src/bin.ts',
         'packages/*/*/src/worker.ts',
+        // The package probe's child entry runs only as a spawned process; probe.spec drives it.
+        'packages/boot/app-boot/src/probe-child.ts',
         // Dynamic Host/Client composition is covered by its focused lifecycle
         // tests and assembled application checks rather than per-file coverage.
         'packages/self-modification/*/src/**/*.{ts,tsx}',