Sfoglia il codice sorgente

Merge branch 'feat/plugin-mgmt-3-settings' into feat/plugin-mgmt-4-web

# Conflicts:
#	packages/boot/app-boot/src/probe.ts
#	packages/boot/app-boot/tests/probe.spec.ts
Yichen Jiang 2 settimane fa
parent
commit
dcbac5ebf2
41 ha cambiato i file con 604 aggiunte e 220 eliminazioni
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 0 4
      apps/cli/src/profile-boot.ts
  5. 2 2
      docs/module-graph.i18n.yaml
  6. 2 1
      docs/module-graph.md
  7. 2 1
      docs/module-graph.zh.md
  8. 2 2
      docs/subsystems/core.i18n.yaml
  9. 16 6
      docs/subsystems/core.md
  10. 16 6
      docs/subsystems/core.zh.md
  11. 2 2
      packages/boot/app-boot/README.i18n.yaml
  12. 3 2
      packages/boot/app-boot/README.md
  13. 3 2
      packages/boot/app-boot/README.zh.md
  14. 35 11
      packages/boot/app-boot/src/compose-stack.ts
  15. 53 24
      packages/boot/app-boot/src/external-bundles.ts
  16. 37 10
      packages/boot/app-boot/src/patch-rows.ts
  17. 11 3
      packages/boot/app-boot/src/probe-child.ts
  18. 3 0
      packages/boot/app-boot/src/probe-report.ts
  19. 97 55
      packages/boot/app-boot/src/probe.ts
  20. 27 15
      packages/boot/app-boot/src/profile-runtime.ts
  21. 45 0
      packages/boot/app-boot/tests/compose-stack.spec.ts
  22. 51 0
      packages/boot/app-boot/tests/external-bundles.spec.ts
  23. 77 30
      packages/boot/app-boot/tests/probe.spec.ts
  24. 22 13
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  25. 2 2
      packages/boot/plugin-manager/src/view.ts
  26. 0 1
      packages/boot/plugin-manager/tests/plugin-manager.spec.ts
  27. 9 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  28. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  29. 1 1
      packages/host/plugin-inventory/README.md
  30. 1 1
      packages/host/plugin-inventory/README.zh.md
  31. 2 3
      packages/host/plugin-inventory/src/index.ts
  32. 5 2
      packages/host/plugin-inventory/src/types.ts
  33. 21 4
      packages/host/plugin-inventory/tests/inventory.spec.ts
  34. 2 1
      packages/subprocess/subprocess/package.json
  35. 4 2
      packages/subprocess/subprocess/src/index.ts
  36. 2 2
      packages/util/launch-environment/README.i18n.yaml
  37. 4 0
      packages/util/launch-environment/README.md
  38. 4 0
      packages/util/launch-environment/README.zh.md
  39. 21 0
      packages/util/launch-environment/src/index.ts
  40. 10 1
      packages/util/launch-environment/tests/launch-environment.spec.ts
  41. 2 0
      scripts/gen-cordis-catalog.ts

+ 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: 2b892cacf14a9f62921b0fce015f14970a2b5a06
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: f7004f83f417282670fb90effadf257716066e67

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

@@ -8,7 +8,7 @@ English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md)
 
 `installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place.
 
-Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
+Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy (judged by package directory, through links and a packaged executable's proxy) or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
 
 ## Decision
 
@@ -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
 

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

@@ -8,7 +8,7 @@ Status: implemented
 
 `installFailLoud` 注册了一个进程级的 `unhandledRejection` 处理器,写出 `fatal load failure` 后退出,而 launcher 丢弃了它返回的卸载函数,于是这个处理器活到进程结束。启动期间这是对的:未处理的 rejection 就是没人会报告的加载失败。启动之后它意味着任何插件的漏网延续——社区组合包忘了 await 的一个被拒 promise——都会把每个会话一起拖下去,而且根本没有 `uncaughtException` 处理器,定时器回调里的一次同步 throw 会让进程带着 Node 的默认堆栈崩掉,没有来源。插件管理器存在的目的正是在运行时挂载三方代码,在这两个默认行为之下这是拿进程赌。
 
-另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema。
+另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema(按包目录判断,穿过符号链接与打包可执行文件的代理包)
 
 ## 决定
 
@@ -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/` 下,按版本失效。
 
 ## 考虑过的替代方案
 

+ 0 - 4
apps/cli/src/profile-boot.ts

@@ -313,10 +313,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
       loadProfile: () => prepareProfile(options.profile),
       compose: composeFor,
       rootEntry: () => rootIncludeEntry(ctx),
-      readUserPatches: () => [
-        ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
-        ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-      ],
     })
     const runtime = ctx.get('profileRuntime')
     if (runtime !== undefined) app.runtime = runtime

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: a946b9fcd7d9108012d6bce515cc7e345ec66d65
-module-graph.zh.md: 4a2408da21dbc2c7cbe41cb7be0a4adff2b2c7e1
+module-graph.md: 08d547ec32d2206e21843a7391011b8834f3e825
+module-graph.zh.md: 3657f89e61c2c3cd050e92bc31679befcbc4f0fd

+ 2 - 1
docs/module-graph.md

@@ -394,6 +394,7 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
+  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
@@ -1274,7 +1275,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 1
docs/module-graph.zh.md

@@ -396,6 +396,7 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
+  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
@@ -1276,7 +1277,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 2
docs/subsystems/core.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 docs/subsystems/core.md
-core.md: b7a1975d18f93eab6c67d7c85852c4ca59593188
-core.zh.md: 0fb0ed4e0470c760ec2d78ae13ff763dcd925f80
+core.md: f257eff317c2c68c9c8aca5145b3b7a4a9773fe3
+core.zh.md: 854876faa6c9d02b5dbde0cdf43ad8336a47b8f9

+ 16 - 6
docs/subsystems/core.md

@@ -960,13 +960,23 @@ Facts and recomposition of the booted profile.
 originOf(rowId: string): RowOrigin | undefined
 
 /**
- * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file stays an expression node when read from
- * disk, so it is a condition, not a user decision, and is left to the
- * composition.
- * @returns the ids, re-read from disk on every call.
+ * Row ids the user patch layers disable with a literal `disabled: true`,
+ * as the committed composition read them. The set describes the running
+ * tree: a user file the include rejected, or one that cannot be parsed,
+ * changes nothing here until a composition with it is accepted.
+ * @returns the ids, from the committed composition.
  */
-userDisabledRowIds(): Set<string>
+userDisabledRowIds(): ReadonlySet<string>
+
+/**
+ * Whether the user patch layers disable an entry: its own row id, or the
+ * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+ * disables every descendant of a disabled group, so a child's own id alone
+ * does not say who switched it off.
+ * @param entry - the Loader entry.
+ * @returns true when the user's patches disable the entry or one of the groups holding it.
+ */
+userDisables(entry: Entry): boolean
 
 /**
  * Recompose the host tree from the profile's layers and the user patch files

+ 16 - 6
docs/subsystems/core.zh.md

@@ -970,13 +970,23 @@ Facts and recomposition of the booted profile.
 originOf(rowId: string): RowOrigin | undefined
 
 /**
- * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file stays an expression node when read from
- * disk, so it is a condition, not a user decision, and is left to the
- * composition.
- * @returns the ids, re-read from disk on every call.
+ * Row ids the user patch layers disable with a literal `disabled: true`,
+ * as the committed composition read them. The set describes the running
+ * tree: a user file the include rejected, or one that cannot be parsed,
+ * changes nothing here until a composition with it is accepted.
+ * @returns the ids, from the committed composition.
  */
-userDisabledRowIds(): Set<string>
+userDisabledRowIds(): ReadonlySet<string>
+
+/**
+ * Whether the user patch layers disable an entry: its own row id, or the
+ * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+ * disables every descendant of a disabled group, so a child's own id alone
+ * does not say who switched it off.
+ * @param entry - the Loader entry.
+ * @returns true when the user's patches disable the entry or one of the groups holding it.
+ */
+userDisables(entry: Entry): boolean
 
 /**
  * Recompose the host tree from the profile's layers and the user patch files

+ 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: ab734b967ddc734b9690e5186df5637a06cdfb8d
-README.zh.md: e1214ec99162df0c2bc1daff6d1c182cba8b01f7
+README.md: dbb3a8bc0e6b0a8c8face297b46bc7d80fcb03be
+README.zh.md: e2e3cdc772157039256dea4a923abace36849527

+ 3 - 2
packages/boot/app-boot/README.md

@@ -117,9 +117,9 @@ This section explains how the outcomes above are realized and points at the code
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
 - **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`. Boot, live recomposition, and `--dump-config` compose through the same function.
+- **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 cordis check compares package directories, through links and through the proxy a packaged executable writes, so `src` and `lib` resolutions of one package agree; 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.
@@ -195,6 +195,7 @@ These limits describe when this boot library is a poor fit or needs special care
 - **Patch edits merge at the key level, not the row level** — `setRowField('x', 'config', value)` replaces the whole `config` mapping of that patch; a caller that wants one nested field changed reads the current value with `rowField` and writes the merged mapping back.
 - **The patch writer authors no expressions** — it emits plain data only; a `!!js` gate is something an author types into the file, never something an API call produces.
 - **Lock orphans are an operator action** — a lock file left by a crashed patch writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice.
+- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 3 - 2
packages/boot/app-boot/README.zh.md

@@ -117,9 +117,9 @@ await mutatePatchFile(file, (document) => {
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
 - **外部组合包即组。** 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` 走同一个函数。
+- **行 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 副本的包只消耗一个子进程,得到一条带原因的记录——cordis 判定比较的是包目录,穿过符号链接与打包可执行文件写出的代理包,因此同一个包的 `src` 与 `lib` 解析结果一致;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`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`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
@@ -195,6 +195,7 @@ await mutatePatchFile(file, (document) => {
 - **补丁编辑按键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。
 - **补丁写入器不生成表达式**——它只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。
 - **锁孤儿由操作者处理**——补丁写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。
+- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 35 - 11
packages/boot/app-boot/src/compose-stack.ts

@@ -58,6 +58,12 @@ export interface ComposedStack {
   readonly conflicts: RowConflict[]
   /** External bundles left out because a row id was already claimed or repeated. */
   readonly skippedBundles: string[]
+  /**
+   * Row ids the user layers disable with a literal `disabled: true`, as
+   * composed; a `!!js` gate stays an expression node when read from disk, so
+   * it is a condition of the composition, not a user decision.
+   */
+  readonly userDisabledRowIds: ReadonlySet<string>
 }
 
 /** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
@@ -93,29 +99,44 @@ function rowIds(row: EntryOptions): string[] {
  * rows it inserts and the rows its config overrides set as a group's
  * children. Built-in and boot-staged layers claim first, in manifest order;
  * an id two of them declare, or one inserts twice, is a defect of the shipped
- * composition and throws, while a config override restating a row the same
- * layer declared is that layer keeping its own child. Contained external
- * layers then claim in manifest order, each rendered once here; one whose id
- * is already owned, or which declares an id twice, is left out whole.
+ * composition and throws, while a config override restating a row under the
+ * group the same layer declared it in is that layer keeping its own child;
+ * the same id twice in one config list, or set under another group, is
+ * declared twice. Contained external layers then claim in manifest order,
+ * each rendered once here; one whose id is already owned, or which declares
+ * an id twice, is left out whole.
  * @param layers - the profile's bundle layers, in manifest order.
  * @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
  * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  */
 export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
+  const declaredUnder = new Map<string, string | undefined>()
   for (const layer of layers) {
     if (isContainedLayer(layer)) continue
-    visitPatchRows(layer.patches, (row, source) => {
+    let listed = new Set<string>()
+    let listIndex = -1
+    visitPatchRows(layer.patches, (row, source, place) => {
       if (typeof row.id !== 'string') return
+      if (place.patch !== listIndex) {
+        listIndex = place.patch
+        listed = new Set()
+      }
       const owner = owners.get(row.id)
       if (owner === layer) {
-        if (source === 'insert') throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
-        return
-      }
-      if (owner !== undefined) {
+        // Restating a row under the group that already holds it is the layer
+        // keeping its child; inserting it again, listing it twice in one
+        // config, or setting it under another group is declaring it twice.
+        if (source === 'insert' || listed.has(row.id) || declaredUnder.get(row.id) !== place.target) {
+          throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
+        }
+      } else if (owner !== undefined) {
         throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
+      } else {
+        owners.set(row.id, layer)
+        declaredUnder.set(row.id, place.target)
       }
-      owners.set(row.id, layer)
+      listed.add(row.id)
     })
   }
   const skipped = new Map<string, RowConflict[]>()
@@ -151,7 +172,7 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param userLayers - the user-owned layers, in application order.
- * @returns the patches to mount, the owner of every bundle id, the conflicts, and the bundles left out.
+ * @returns the patches to mount, the owner of every bundle id, the conflicts, the bundles left out, and the rows the user layers disable.
  * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  */
 export function composeProfileStack(
@@ -178,10 +199,12 @@ export function composeProfileStack(
   }
   const claimed = new Map<string, string>()
   for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
+  const userDisabledRowIds = new Set<string>()
   for (const userLayer of userLayers) {
     const patches: PatchOptions[] = []
     for (const patch of userLayer.patches) {
       if (patch.insert === undefined) {
+        if (patch.id !== undefined && patch.disabled === true) userDisabledRowIds.add(patch.id)
         patches.push(patch)
         continue
       }
@@ -207,6 +230,7 @@ export function composeProfileStack(
     owners: ownership.owners,
     conflicts,
     skippedBundles,
+    userDisabledRowIds,
   }
 }
 

+ 53 - 24
packages/boot/app-boot/src/external-bundles.ts

@@ -59,7 +59,11 @@ export interface ComposedExternalLayer {
    * names; a repeated id keeps its first module.
    */
   rows: Map<string, string>
-  /** Ids the layer introduces more than once, in order of repetition; a config override restating a row is not one. */
+  /**
+   * Ids the layer introduces more than once, in order of repetition. A config
+   * override restating a row under the group that already holds it is not
+   * one; the same id twice in one config list, or set under another group, is.
+   */
   duplicates: DuplicateRow[]
   /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
   overrides: string[]
@@ -87,7 +91,9 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
  * an override when it addresses a row the bundle did not introduce; the rows
  * it sets as a group's config count as the bundle's own, since they mount
  * as children like inserted ones. Every id, declared or generated, goes
- * through one registration, so a row spelling a wrapper's id is a duplicate.
+ * through one registration, so a row spelling a wrapper's id is a duplicate,
+ * and so is a config row the bundle already declared under another group,
+ * or listed twice: the Loader would move the first and reject the second.
  * Ids are indexed before any patch is emitted, so an insert into a group the
  * bundle introduces later in its list still counts as its own.
  * @param layer - the resolved external layer.
@@ -96,20 +102,40 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
 export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
   const groupId = bundleGroupId(layer.packageName)
   const rows = new Map<string, string>()
+  const declaredUnder = new Map<string, string | undefined>()
   const duplicates: DuplicateRow[] = []
-  const claim = (rowId: string, moduleName: string): void => {
-    if (rows.has(rowId)) duplicates.push({ rowId, moduleName })
-    else rows.set(rowId, moduleName)
+  const claim = (rowId: string, moduleName: string, target?: string): void => {
+    if (rows.has(rowId)) {
+      duplicates.push({ rowId, moduleName })
+      return
+    }
+    rows.set(rowId, moduleName)
+    declaredUnder.set(rowId, target)
   }
-  visitPatchRows(layer.patches, (row, source) => {
+  let listed = new Set<string>()
+  let listIndex = -1
+  visitPatchRows(layer.patches, (row, source, place) => {
     if (typeof row.id !== 'string') return
-    // A config override restates the children it keeps: the id counts as
-    // the bundle's own without being a repeat.
+    if (place.patch !== listIndex) {
+      listIndex = place.patch
+      listed = new Set()
+    }
+    // A root insert lands in the bundle's group: that is the group it declares under.
+    const target = place.target ?? groupId
     if (source === 'config') {
-      if (!rows.has(row.id)) rows.set(row.id, row.name)
-      return
+      // A config override restates the children it keeps: the same id declared
+      // under the same group before is that row. Twice in one list, or under
+      // another group, it would mount as a rejected duplicate or move the row.
+      if (listed.has(row.id) || (rows.has(row.id) && declaredUnder.get(row.id) !== target)) {
+        duplicates.push({ rowId: row.id, moduleName: row.name })
+      } else if (!rows.has(row.id)) {
+        rows.set(row.id, row.name)
+        declaredUnder.set(row.id, target)
+      }
+    } else {
+      claim(row.id, row.name, target)
     }
-    claim(row.id, row.name)
+    listed.add(row.id)
   })
   claim(groupId, CONTAINED_GROUP_MODULE)
   const wrappers = new Map<string, string>()
@@ -176,7 +202,7 @@ export interface BundleReconciliation {
   removed: string[]
   /** Newly added dependencies that declare no `dsh.bundle` (plain libraries or plugin modules). */
   plain: string[]
-  /** Installed bundles left out of the layer list because `autoEnable` was off. */
+  /** Bundles the run installed and left out of the layer list because `autoEnable` was off. */
   installedOnly: string[]
 }
 
@@ -184,9 +210,11 @@ export interface BundleReconciliation {
  * Reconcile `dsh.profile.bundles` against the installed state after a pnpm
  * run. A dependency that no longer resolves to a bundle leaves the layer
  * list; template bundles (never dependencies) are untouched. A dependency
- * that resolves to a bundle joins the list only when `autoEnable` is set —
- * the CLI's install-and-enable semantics — and is otherwise reported as
- * installed-only, which is the plugin manager's install step.
+ * the run added that resolves to a bundle joins the list only when
+ * `autoEnable` is set — the CLI's install-and-enable semantics — and is
+ * otherwise reported as installed-only, which is the plugin manager's install
+ * step. A bundle installed before the run keeps its place in or out of the
+ * list: one the user disabled stays disabled through an unrelated run.
  * @param binName - the diagnostic prefix used by manifest reads.
  * @param profileDir - the profile directory.
  * @param installAnchor - absolute path of the dsh app's package.json.
@@ -207,16 +235,17 @@ export function reconcileInstalledBundles(
   const bundles = [...after.dsh?.profile?.bundles ?? []]
   const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] }
   for (const packageName of dependencies) {
-    const isBundle = exportsBundlePatch(binName, packageName, installAnchor, profileDir)
-    if (isBundle && !bundles.includes(packageName)) {
-      if (options.autoEnable) {
-        bundles.push(packageName)
-        outcome.enabled.push(packageName)
-      } else {
-        outcome.installedOnly.push(packageName)
-      }
-    } else if (!isBundle && !beforeDeps.has(packageName)) {
+    if (beforeDeps.has(packageName)) continue
+    if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) {
       outcome.plain.push(packageName)
+      continue
+    }
+    if (bundles.includes(packageName)) continue
+    if (options.autoEnable) {
+      bundles.push(packageName)
+      outcome.enabled.push(packageName)
+    } else {
+      outcome.installedOnly.push(packageName)
     }
   }
   const dependencySet = new Set(dependencies)

+ 37 - 10
packages/boot/app-boot/src/patch-rows.ts

@@ -11,6 +11,22 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 /** How a patch introduced a row: inserted it, or set it as a group's `config`. */
 export type PatchRowSource = 'insert' | 'config'
 
+/**
+ * Where a patch introduced a row. `target` is the id of the group the row is
+ * declared under — the patch's target, or the enclosing group row for a nested
+ * one — undefined for a root-level insert, and a private marker for a child of
+ * a group without an id, which no later patch can address. `patch` is the
+ * index of the patch in the list, so the same id twice in one config list can
+ * be told from a restatement across patches.
+ */
+export interface PatchRowPlace {
+  readonly target: string | undefined
+  readonly patch: number
+}
+
+/** The target of rows nested in a group that declares no id: nothing can restate them, so nothing may match it. */
+const ANONYMOUS_GROUP = '\u0000anonymous-group'
+
 /** Whether a config item is a row: a plain object naming a module. */
 function isRowLike(value: unknown): value is EntryOptions {
   return typeof value === 'object' && value !== null && !Array.isArray(value) && typeof (value as { name?: unknown }).name === 'string'
@@ -23,9 +39,17 @@ function isRowLike(value: unknown): value is EntryOptions {
  * @param visit - called once per row.
  */
 export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => void): void {
-  visit(row)
+  visitPlacedRows(row, undefined, (entry) => { visit(entry) })
+}
+
+/** The walk behind {@link visitRowTree}, carrying the group each row is declared under. */
+function visitPlacedRows(
+  row: EntryOptions, target: string | undefined, visit: (row: EntryOptions, target: string | undefined) => void,
+): void {
+  visit(row, target)
   if (row.group && Array.isArray(row.config)) {
-    for (const child of row.config as EntryOptions[]) visitRowTree(child, visit)
+    const own = typeof row.id === 'string' ? row.id : ANONYMOUS_GROUP
+    for (const child of row.config as EntryOptions[]) visitPlacedRows(child, own, visit)
   }
 }
 
@@ -35,17 +59,20 @@ export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => vo
  * group's `config`, which mount as that group's children the same way an
  * insert's do.
  * @param patches - the patch list.
- * @param visit - called once per row, with how the patch introduced it.
+ * @param visit - called once per row, with how the patch introduced it and where.
  */
-export function visitPatchRows(patches: readonly PatchOptions[], visit: (row: EntryOptions, source: PatchRowSource) => void): void {
-  for (const patch of patches) {
+export function visitPatchRows(
+  patches: readonly PatchOptions[],
+  visit: (row: EntryOptions, source: PatchRowSource, place: PatchRowPlace) => void,
+): void {
+  patches.forEach((patch, index) => {
     if (patch.insert !== undefined) {
-      for (const row of patch.insert) visitRowTree(row, (entry) => { visit(entry, 'insert') })
-      continue
+      for (const row of patch.insert) visitPlacedRows(row, patch.id, (entry, target) => { visit(entry, 'insert', { target, patch: index }) })
+      return
     }
-    if (patch.id === undefined || !Array.isArray(patch.config)) continue
+    if (patch.id === undefined || !Array.isArray(patch.config)) return
     for (const item of patch.config as unknown[]) {
-      if (isRowLike(item)) visitRowTree(item, (entry) => { visit(entry, 'config') })
+      if (isRowLike(item)) visitPlacedRows(item, patch.id, (entry, target) => { visit(entry, 'config', { target, patch: index }) })
     }
-  }
+  })
 }

+ 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

+ 97 - 55
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, realpathSync, writeFileSync } from 'node:fs'
 import { dirname, 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[] = []
@@ -232,13 +262,7 @@ export async function probePackage(options: ProbeOptions): Promise<PluginProbe>
     : describeBundlePatch(options.binName, join(packageDir, declaredBundle))
   const hasMain = manifest.main !== undefined || manifest.exports !== undefined
   const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name))
-  const harnessCordis = resolveHarnessCordis()
-  // Compared by package directory, not by entry file: a source launch
-  // resolves the harness's copy to its TypeScript entry while the child, a
-  // plain Node process, resolves the same package's built entry.
-  const cordisSameCopy = report.cordis === null || harnessCordis === undefined
-    ? null
-    : cordisPackageDir(report.cordis) === cordisPackageDir(harnessCordis)
+  const cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis())
   const kind: PluginProbe['kind'] = declaredBundle !== undefined
     ? 'bundle'
     : report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library'
@@ -280,13 +304,13 @@ export async function probePackage(options: ProbeOptions): Promise<PluginProbe>
 }
 
 /**
- * The copy of `@deepseek-ai/cordis` this harness runs: the one this module
- * resolves, which is the one every built-in plugin shares. Undefined only in
- * an environment that cannot resolve it at all.
+ * The package directory of the `@deepseek-ai/cordis` copy this harness runs:
+ * the one this module resolves, which every built-in plugin shares. Undefined
+ * only in an environment that cannot resolve it at all.
  */
 function resolveHarnessCordis(): string | undefined {
   try {
-    return import.meta.resolve('@deepseek-ai/cordis')
+    return cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
   } catch {
     // Only an embedder without cordis on its module path lands here; the
     // harness itself always resolves its own peer.
@@ -296,30 +320,48 @@ function resolveHarnessCordis(): string | undefined {
 }
 
 /**
- * The real directory of the `@deepseek-ai/cordis` package a resolved entry
- * URL belongs to: the nearest ancestor whose manifest carries that name.
- * @param url - a `file:` URL of one cordis entry module.
- * @returns the package directory with symlinks resolved, or the entry path
- * itself when no such manifest is found above it.
+ * Whether the package's cordis is the harness's: the two package directories
+ * compare equal. Null when the package resolves no cordis, when the harness
+ * cannot resolve its own, or when no cordis manifest encloses what the
+ * package resolved, since none of those says the package brought a copy.
+ */
+function sameCordisCopy(reported: string | null, harness: string | undefined): boolean | null {
+  if (reported === null) return null
+  /* v8 ignore next -- the harness resolves its own peer; only an embedder without cordis lands here */
+  if (harness === undefined) return null
+  const dir = cordisPackageDir(reported)
+  return dir === undefined ? null : dir === harness
+}
+
+/** The manifest fields that tell a dsh module proxy from the package it stands for. */
+interface ProxyAwareManifest {
+  name?: unknown
+  dsh?: { moduleFallback?: { targets?: Record<string, string> } }
+}
+
+/**
+ * The directory of the `@deepseek-ai/cordis` package a resolved module URL
+ * belongs to, with symlinks resolved and a dsh module proxy followed to the
+ * package it re-exports. Two resolutions of one installed package compare
+ * equal this way whether they landed on `src` or `lib`, on a link or its
+ * target, or on the proxy a packaged executable writes for its peers; the
+ * URLs themselves differ in every one of those cases.
+ * @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave.
+ * @returns the real package directory, or undefined when no cordis manifest encloses the URL.
  */
-function cordisPackageDir(url: string): string {
-  let dir = dirname(fileURLToPath(url))
+export function cordisPackageDir(resolved: string): string | undefined {
+  let dir = dirname(fileURLToPath(resolved))
   for (;;) {
-    const manifest = join(dir, 'package.json')
-    if (existsSync(manifest)) {
-      let name: unknown
-      try {
-        name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
-      } catch {
-        // A manifest Node's resolver did not read — an unrelated ancestor's,
-        // malformed — is not cordis's; keep climbing.
-        /* v8 ignore next */
-        name = undefined
+    const manifestPath = join(dir, 'package.json')
+    if (existsSync(manifestPath)) {
+      const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProxyAwareManifest
+      if (manifest.name === '@deepseek-ai/cordis') {
+        const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
+        return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
       }
-      if (name === '@deepseek-ai/cordis') return realpathSync(dir)
     }
     const parent = dirname(dir)
-    if (parent === dir) return fileURLToPath(url)
+    if (parent === dir) return undefined
     dir = parent
   }
 }

+ 27 - 15
packages/boot/app-boot/src/profile-runtime.ts

@@ -18,7 +18,6 @@
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import type { ComposedStack, RowConflict } from './compose-stack.ts'
 import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
@@ -54,8 +53,6 @@ export interface ProfileRuntimeOptions {
   compose: (profile: Profile) => ComposedStack
   /** The root Include entry, once mounted. */
   rootEntry: () => Entry | undefined
-  /** The user patch layers as they stand on disk (profile file, then home file). */
-  readUserPatches: () => PatchOptions[]
 }
 
 /** The profile and stack the tree runs, published together once the root include accepted the stack. */
@@ -131,19 +128,26 @@ export class ProfileRuntime extends Service {
   }
 
   /**
-   * Row ids the user patch layers disable with a literal `disabled: true`.
-   * A `!!js` gate in a user file stays an expression node when read from
-   * disk, so it is a condition, not a user decision, and is left to the
-   * composition.
-   * @returns the ids, re-read from disk on every call.
+   * Row ids the user patch layers disable with a literal `disabled: true`,
+   * as the committed composition read them. The set describes the running
+   * tree: a user file the include rejected, or one that cannot be parsed,
+   * changes nothing here until a composition with it is accepted.
+   * @returns the ids, from the committed composition.
    */
-  userDisabledRowIds(): Set<string> {
-    const ids = new Set<string>()
-    for (const patch of this.options.readUserPatches()) {
-      if (patch.insert !== undefined || patch.id === undefined) continue
-      if (patch.disabled === true) ids.add(patch.id)
-    }
-    return ids
+  userDisabledRowIds(): ReadonlySet<string> {
+    return this.committed.stack.userDisabledRowIds
+  }
+
+  /**
+   * Whether the user patch layers disable an entry: its own row id, or the
+   * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+   * disables every descendant of a disabled group, so a child's own id alone
+   * does not say who switched it off.
+   * @param entry - the Loader entry.
+   * @returns true when the user's patches disable the entry or one of the groups holding it.
+   */
+  userDisables(entry: Entry): boolean {
+    return userDisablesEntry(entry, this.committed.stack.userDisabledRowIds)
   }
 
   /**
@@ -183,3 +187,11 @@ export class ProfileRuntime extends Service {
     this.committed = { profile, stack }
   }
 }
+
+/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */
+function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet<string>): boolean {
+  for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) {
+    if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
+  }
+  return false
+}

+ 45 - 0
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -73,6 +73,40 @@ describe('claimLayerIds', () => {
       { id: 'h', config: [{ id: 'settings', name: 'taking/impostor' }] },
     ])
     expect(() => claimLayerIds([base, taking])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and taking/)
+    // Setting a row under another group would move it; listing it twice would fail the group's update.
+    const moving = layer('moving', 'builtin', [
+      { insert: [
+        { id: 'g', name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] },
+        { id: 'h', name: 'cordis:group', group: true, config: [] },
+      ] },
+      { id: 'h', config: [{ id: 'a', name: 'a' }] },
+    ])
+    expect(() => claimLayerIds([moving])).toThrow(/row "a" is declared twice by moving/)
+    const twice = layer('twice', 'builtin', [
+      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [] }] },
+      { id: 'g', config: [{ id: 'a', name: 'a' }, { id: 'a', name: 'a' }] },
+    ])
+    expect(() => claimLayerIds([twice])).toThrow(/row "a" is declared twice by twice/)
+    // A child of a group without an id cannot be restated: no patch can address that group.
+    const anonymous = layer('anonymous', 'builtin', [
+      { insert: [
+        // A row leaves its id to the Loader, as YAML rows may; the type asks for one.
+        { name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] } as unknown as EntryOptions,
+        { id: 'h', name: 'cordis:group', group: true, config: [] },
+      ] },
+      { id: 'h', config: [{ id: 'a', name: 'a' }] },
+    ])
+    expect(() => claimLayerIds([anonymous])).toThrow(/row "a" is declared twice by anonymous/)
+  })
+
+  it('leaves out a bundle whose config override of its own group lists an id twice', () => {
+    const self = layer('self', 'external', [
+      { insert: [{ id: 'row', name: 'self/row' }] },
+      { id: 'bundle/self', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
+    ])
+    const { skipped, composed } = claimLayerIds([base, self])
+    expect(skipped.get('self')?.map(conflict => conflict.message)).toEqual(['row "row" is declared twice by self'])
+    expect(composed.has('self')).toBe(false)
   })
 
   it('leaves out a bundle that declares one of its own ids twice and composes each mounted bundle once', () => {
@@ -100,6 +134,17 @@ describe('claimLayerIds', () => {
 })
 
 describe('composeProfileStack', () => {
+  it('collects the rows the user layers disable with a literal disabled: true', () => {
+    const stack = composeProfileStack(NAME, [base], [{ label: '/p/cordis.patch.yml', patches: [
+      { id: 'a', disabled: true },
+      // A gate read from disk is an expression node: a condition of the composition, not a decision.
+      { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
+      { id: 'c', config: {} },
+      { insert: [{ id: 'd', name: 'x', disabled: true }] },
+    ] }])
+    expect([...stack.userDisabledRowIds]).toEqual(['a'])
+  })
+
   it('mounts owning layers in manifest order and drops a user insert of a taken id', () => {
     const ext = layer('ext', 'external', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }])
     const stack = composeProfileStack(NAME, [base, ext], [

+ 51 - 0
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -163,6 +163,35 @@ describe('composeExternalLayer', () => {
     expect(composed.rows.get('dup')).toBe('pkg-r/one')
   })
 
+  it('reports a config override that lists one id twice or sets a row under a group other than the one holding it', () => {
+    const composed = composeExternalLayer(layer('pkg-o', [
+      { insert: [
+        { id: 'a', name: 'cordis:group', group: true, config: [{ id: 'shared', name: 'pkg-o/x' }] },
+        { id: 'b', name: 'cordis:group', group: true, config: [] },
+      ] },
+      { id: 'b', config: [{ id: 'shared', name: 'pkg-o/moved' }] },
+      { id: 'a', config: [{ id: 'twice', name: 'pkg-o/one' }, { id: 'twice', name: 'pkg-o/two' }] },
+      // The bundle's own group is a predictable target: a duplicate under it
+      // would fail the group's update before any row is contained.
+      { id: 'bundle/pkg-o', config: [{ id: 'own', name: 'pkg-o/own' }, { id: 'own', name: 'pkg-o/own-again' }] },
+    ]))
+    expect(composed.duplicates).toEqual([
+      { rowId: 'shared', moduleName: 'pkg-o/moved' },
+      { rowId: 'twice', moduleName: 'pkg-o/two' },
+      { rowId: 'own', moduleName: 'pkg-o/own-again' },
+    ])
+    expect(composed.overrides).toEqual([])
+  })
+
+  it('lets a config override restate a row under the built-in group it was inserted into', () => {
+    const composed = composeExternalLayer(layer('pkg-s', [
+      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-s/tool' }] },
+      { id: 'tools', config: [{ id: 'tool', name: 'pkg-s/tool' }] },
+    ]))
+    expect(composed.duplicates).toEqual([])
+    expect(composed.overrides).toEqual(['tools'])
+  })
+
   it('never mutates the layer\'s own patch objects', () => {
     const patches: PatchOptions[] = [{ insert: [{ id: 'row', name: 'pkg-d' }] }, { id: 'row', config: { a: 1 } }]
     const snapshot = structuredClone(patches)
@@ -242,6 +271,28 @@ describe('reconcileInstalledBundles', () => {
     expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false)
   })
 
+  it('leaves a bundle installed before the run where the user left it, in or out of the list', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'new-bundle': { bundle: true } })
+    // ext-bundle was installed earlier and is not listed: the user disabled it.
+    const before = { dependencies: { 'ext-bundle': '1.0.0' } }
+    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true }))
+      .toEqual({ enabled: ['new-bundle'], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'new-bundle'])
+    disableBundle(NAME, profileDir, 'new-bundle')
+    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, readProfileManifest(NAME, profileDir), { autoEnable: true }))
+      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
+  })
+
+  it('keeps a listed bundle listed once when the run brings its dependency back', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
+    enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')
+    // The manifest the run started with had lost the dependency; pnpm added it again.
+    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, { dependencies: {} }, { autoEnable: true }))
+      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle'])
+  })
+
   it('drops a layer whose dependency was removed and keeps template bundles', () => {
     const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
     enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')

+ 77 - 30
packages/boot/app-boot/tests/probe.spec.ts

@@ -3,13 +3,12 @@
  * a child process, and the per-profile cache.
  */
 
-import { mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
 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 { cordisPackageDir, parseProbeRecord } from '../src/probe.ts'
 import { parseChildReport, type ChildReport } from '../src/probe-report.ts'
 
 const NAME = 'dsh-test-bin'
@@ -130,16 +129,6 @@ describe('probePackage', () => {
           'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
         },
       },
-      // A copy whose manifest carries another name still resolves as cordis
-      // for Node; with no cordis manifest above its entry, the entry itself
-      // stands for the copy, which is not the harness's.
-      'renamed-cordis': {
-        main: 'export function apply() {}\n',
-        files: {
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: 'not-cordis', version: '0.0.0', type: 'module', main: './index.js' }),
-          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
-        },
-      },
     })
     const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' })
     expect(broken.ok).toBe(false)
@@ -150,21 +139,46 @@ describe('probePackage', () => {
     expect(own.cordisSameCopy).toBe(false)
     expect(own.ok).toBe(false)
     expect(own.reason).toContain('own copy of @deepseek-ai/cordis')
-    const renamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'renamed-cordis' })
-    expect(renamed.cordisSameCopy).toBe(false)
   })
 
-  it('recognizes the harness copy of cordis by its package directory, whichever entry each side resolved', async () => {
-    // The staged package reaches cordis through a symlink to the harness's
-    // own package; the child resolves its built entry, this process (under
-    // the test runner's source aliases) its TypeScript entry.
-    const { profileDir, installAnchor } = stage({ 'shared-cordis': { main: 'export function apply() {}\n' } })
-    const harnessCordis = fileURLToPath(new URL('../../../../vendor/cordis', import.meta.url))
-    mkdirSync(join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai'), { recursive: true })
-    symlinkSync(harnessCordis, join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai', 'cordis'), 'dir')
-    const shared = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'shared-cordis' })
-    expect(shared.cordisSameCopy).toBe(true)
-    expect(shared.ok).toBe(true)
+  it('recognizes the harness\'s cordis through a link and through a packaged executable\'s proxy', async () => {
+    const harnessCordis = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis')) as string
+    const harnessEntry = import.meta.resolve('@deepseek-ai/cordis')
+    const { profileDir, installAnchor } = stage({
+      'linked': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } },
+      // A cordis directory whose manifest names no package: an unknown copy, not a second one.
+      'unnamed': {
+        main: 'export function apply() {}\n',
+        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
+        files: {
+          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ version: '0.0.0', type: 'module', main: './index.js' }),
+          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
+        },
+      },
+      'proxied': {
+        main: 'export function apply() {}\n',
+        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
+        files: {
+          // The proxy a packaged executable writes: a manifest naming its targets, and an entry that re-exports one.
+          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({
+            name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', exports: { '.': './entry-0.js' },
+            dsh: { moduleFallback: { targets: { '.': harnessEntry } } },
+          }),
+          'node_modules/@deepseek-ai/cordis/entry-0.js': `export * from ${JSON.stringify(harnessEntry)}\n`,
+        },
+      },
+    })
+    // The profile links the harness's own cordis, as plain Node installs do.
+    mkdirSync(join(profileDir, 'node_modules', '@deepseek-ai'), { recursive: true })
+    symlinkSync(harnessCordis, join(profileDir, 'node_modules', '@deepseek-ai', 'cordis'), 'dir')
+    const linked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'linked' })
+    expect(linked).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
+    const proxied = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'proxied' })
+    expect(proxied).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
+    const unnamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'unnamed' })
+    expect(unnamed).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: null })
+    // A URL no cordis manifest encloses is an unknown copy, not a different one.
+    expect(cordisPackageDir(`file://${tmpdir()}/nowhere/index.js`)).toBeUndefined()
   })
 
   it('probes a package with the minimal manifest and no main export', async () => {
@@ -201,6 +215,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 () => {
@@ -208,14 +254,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' }))
@@ -274,11 +321,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()

+ 22 - 13
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -24,7 +24,7 @@ function profile(layers: ProfileLayer[]): Profile {
 
 async function harness(
   layers: ProfileLayer[],
-  options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
+  options: { rootEntry?: () => Entry | undefined; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
@@ -37,6 +37,7 @@ async function harness(
       owners: claimLayerIds(current.layers).owners,
       conflicts: current === options.reloaded ? options.conflicts ?? [] : [],
       skippedBundles: [],
+      userDisabledRowIds: new Set(current === options.reloaded ? ['reloaded-off'] : ['booted-off']),
     }
   })
   const booted = profile(layers)
@@ -47,7 +48,6 @@ async function harness(
     loadProfile: () => options.reloaded ?? profile(layers),
     compose,
     rootEntry: options.rootEntry ?? (() => undefined),
-    readUserPatches: () => options.userPatches ?? [],
   })
   return { ctx, runtime: ctx.profileRuntime, compose }
 }
@@ -93,16 +93,24 @@ describe('ProfileRuntime', () => {
     expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' })
   })
 
-  it('reads user-disabled rows from literal disabled: true items only', async () => {
-    const { runtime } = await harness([], {
-      userPatches: [
-        { id: 'a', disabled: true },
-        { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
-        { id: 'c', config: {} },
-        { insert: [{ id: 'd', name: 'x', disabled: true }] },
-      ],
-    })
-    expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
+  it('reports the user-disabled rows of the committed composition and keeps them when the include rejects an update', async () => {
+    const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update: vi.fn(async () => { throw new Error('rejected') }) } as unknown as Entry
+    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
+    expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
+    await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
+    expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
+  })
+
+  it('tells a row the user disabled through a group holding it from one the composition gates', async () => {
+    const { runtime } = await harness([layer('a', 'builtin', [])])
+    const chain = (ids: string[]): Entry => ids.reduceRight<Entry | undefined>(
+      (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined,
+    ) as Entry
+    // The booted composition disables 'booted-off'.
+    expect(runtime.userDisables(chain(['kid', 'booted-off', 'root']))).toBe(true)
+    expect(runtime.userDisables(chain(['booted-off', 'grp']))).toBe(true)
+    expect(runtime.userDisables(chain(['kid', 'grp']))).toBe(false)
   })
 
   it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
@@ -120,9 +128,10 @@ describe('ProfileRuntime', () => {
     await runtime.recompose({ reloadBundles: true })
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
-    // Provenance and conflicts follow the reloaded profile once the update holds.
+    // Provenance, conflicts, and the user-disabled rows follow the reloaded profile once the update holds.
     expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
     expect(runtime.conflicts).toEqual(conflicts)
+    expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
   })
 
   it('runs recompositions one at a time, each from what the previous one committed', async () => {

+ 2 - 2
packages/boot/plugin-manager/src/view.ts

@@ -129,7 +129,6 @@ function packageStatus(facts: {
 /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
 function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
   const failures = ctx.get('pluginFailures')
-  const userDisabled = runtime.userDisabledRowIds()
   const rows: PluginPackageRowView[] = []
   const listed = new Set<string>()
   for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) {
@@ -140,7 +139,8 @@ function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): Plug
       rowId,
       moduleName: entry.options.name,
       enabled: !entry.disabled,
-      ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
+      // The runtime walks the groups holding the entry: a row inside a group the user disabled is the user's doing.
+      ...entry.disabled ? { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const } : {},
       phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
       ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
     })

+ 0 - 1
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -219,7 +219,6 @@ async function bootProfile(staged: StagedHome, internals: Internals = {}, config
     loadProfile: load,
     compose: composeFor,
     rootEntry: () => rootIncludeEntry(ctx),
-    readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
   })
   const changes: Booted['changes'] = []
   const log: PluginInstallLogChunk[] = []

+ 9 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1476,10 +1476,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
       },
       {
-        signature: 'userDisabledRowIds(): Set<string>',
-        description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file stays an expression node when read from disk, so it is a condition, not a user decision, and is left to the composition.',
+        signature: 'userDisabledRowIds(): ReadonlySet<string>',
+        description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.',
         parameters: [],
-        returns: 'the ids, re-read from disk on every call.',
+        returns: 'the ids, from the committed composition.',
+      },
+      {
+        signature: 'userDisables(entry: Entry): boolean',
+        description: 'Whether the user patch layers disable an entry: its own row id, or the id of a group holding it, is among userDisabledRowIds. The Loader disables every descendant of a disabled group, so a child\'s own id alone does not say who switched it off.',
+        parameters: [{ name: 'entry', description: 'the Loader entry.' }],
+        returns: 'true when the user\'s patches disable the entry or one of the groups holding it.',
       },
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',

+ 2 - 2
packages/host/plugin-inventory/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/host/plugin-inventory/README.md
-README.md: 67383f9c6a5c371a2a9bb2da6f13ee1df7a5ed2c
-README.zh.md: f639d8b54f3e6b8fc9a83d497b1a462e7ba84720
+README.md: 02b2101a3b399abde6fdb0924228edffc240b881
+README.zh.md: 867b05779b61e4716c2dedc80623991eab32a2f4

+ 1 - 1
packages/host/plugin-inventory/README.md

@@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is
 
 Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped.
 
-When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
+When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it, or a group holding it, with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
 
 ### Per-preset compositions
 

+ 1 - 1
packages/host/plugin-inventory/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
 
-当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
+当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
 
 ### 每个预设的组合
 

+ 2 - 3
packages/host/plugin-inventory/src/index.ts

@@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
     const entries: PluginInventoryEntry[] = []
     const runtime = this.ctx.get('profileRuntime')
     const failures = this.ctx.get('pluginFailures')
-    const userDisabled = runtime?.userDisabledRowIds() ?? new Set<string>()
     const listed = new Set<string>()
     for (const entry of this.ctx.loader.entries()) {
       if (entry.options.group) continue
@@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
         fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
         trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
-        ...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const },
+        ...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
       })
     }
@@ -125,7 +124,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
           moduleName: conflict.moduleName,
           enabled: true,
           fiberPhase: 'failed',
-          trust: conflict.packageName === undefined ? 'builtin' : 'external',
+          trust: conflict.packageName === undefined ? 'user' : 'external',
           ...conflict.packageName === undefined
             ? {}
             : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },

+ 5 - 2
packages/host/plugin-inventory/src/types.ts

@@ -12,8 +12,11 @@ export type PluginFiberPhase =
   | 'unloading'
   | null
 
-/** Who supplied a Loader row: the installation itself or an installed external bundle. */
-export type PluginTrust = 'builtin' | 'external'
+/**
+ * Who supplied a Loader row: the installation itself, an installed external
+ * bundle, or the user's own patch file (a row of theirs the composition left out).
+ */
+export type PluginTrust = 'builtin' | 'external' | 'user'
 
 /** Why a row is disabled: the composition's own gate or tombstone, or the user's patch layer. */
 export type PluginDisabledBy = 'composition' | 'user'

+ 21 - 4
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -1,9 +1,9 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
-import { ensurePluginFailures, type ProfileRuntime, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
+import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
 import PluginInventoryGateway from '../src/index.ts'
 
 const contexts: Context[] = []
@@ -111,7 +111,7 @@ describe('PluginInventoryGateway', () => {
     ])
     ctx.provide('profileRuntime', {
       originOf: (rowId: string) => origins.get(rowId),
-      userDisabledRowIds: () => new Set([off]),
+      userDisables: (entry: { id: string }) => entry.id === off,
       layers: [{ packageName: 'late', version: '9.9.9' }],
       conflicts: [
         { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
@@ -143,7 +143,7 @@ describe('PluginInventoryGateway', () => {
         package: { name: 'late', version: '9.9.9' }, failure: { stage: 'conflict', message: 'row "tool" is already declared by ext' },
       },
       {
-        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'builtin',
+        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'user',
         failure: { stage: 'conflict', message: 'row "mine" is already declared by ext' },
       },
       // A bundle the layer list no longer names keeps its package, without a version.
@@ -154,6 +154,23 @@ describe('PluginInventoryGateway', () => {
     ].sort(byId))
   })
 
+  it('attributes a row disabled through a group the user disabled to the user', async () => {
+    const { ctx, inventory } = await harness()
+    ctx.loader.builtins.group = Group
+    const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ name: 'cordis:active' }] })
+    const child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId)
+    expect(child).toBeDefined()
+    // The real runtime over a composition whose user layer disabled the group.
+    const profile: Profile = { name: 'web', dir: '/p', layers: [], patchPath: '/p/cordis.patch.yml', patches: [], patchReload: 'live' }
+    const stack: ComposedStack = {
+      patches: [], layers: [], owners: new Map(), conflicts: [], skippedBundles: [], userDisabledRowIds: new Set([groupId]),
+    }
+    await ctx.plugin(ProfileRuntime, { profile, stack, loadProfile: () => profile, compose: () => stack, rootEntry: () => undefined })
+    await ctx.loader.update(groupId, { disabled: true })
+    const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id)
+    expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' })
+  })
+
   it('carries each composed preset with root-fiber states mapped to phases', async () => {
     const { ctx, inventory } = await harness()
     ctx.provide('agentPresets', {

+ 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' } },

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -664,6 +664,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'AsyncIterable',
   'Context',
   'Error',
+  'Entry',
   'EntryTree',
   'Exclude',
   'Extract',
@@ -676,6 +677,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'Record',
   'Readonly',
   'Set',
+  'ReadonlySet',
   'Uint8Array',
 ])