diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml index c8ab0a5710..dd05628ea3 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md index c0ae61e132..2b892cacf1 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md index d4e136e36d..f7004f83f4 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md +++ b/.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/` 下,按版本失效。 ## 考虑过的替代方案 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8ca93506b4..9384e40c04 100644 --- a/apps/cli/src/profile-boot.ts +++ b/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 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 601c828a0f..621be73f36 100644 --- a/docs/module-graph.i18n.yaml +++ b/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: e41311f6207bd54025c8fe1d38dc67e87aebf103 -module-graph.zh.md: 168c8476dd45030a3a0c1031bc3e8641cba723ff +module-graph.md: b893880a2c5ee7710de6bce79f8c7a2be9ab2e41 +module-graph.zh.md: 0b4d42d862d25ec25814fb922486bbbf9a516e52 diff --git a/docs/module-graph.md b/docs/module-graph.md index e41311f620..b893880a2c 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 168c8476dd..0b4d42d862 100644 --- a/docs/module-graph.zh.md +++ b/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) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 51b84f6af7..87da8caa5d 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/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 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index b7a1975d18..f257eff317 100644 --- a/docs/subsystems/core.md +++ b/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 +userDisabledRowIds(): ReadonlySet + +/** + * 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 diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 0fb0ed4e04..854876faa6 100644 --- a/docs/subsystems/core.zh.md +++ b/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 +userDisabledRowIds(): ReadonlySet + +/** + * 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 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 6c71053a9c..87b5affcc1 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/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 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index ab734b967d..dbb3a8bc0e 100644 --- a/packages/boot/app-boot/README.md +++ b/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. ### Dev Note diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index e1214ec991..e2e3cdc772 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/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,直到子进程入口像模块代理那样被物化到可执行文件之外。 ### 开发备注 diff --git a/packages/boot/app-boot/src/compose-stack.ts b/packages/boot/app-boot/src/compose-stack.ts index 3ca6db1200..9f072bdb1d 100644 --- a/packages/boot/app-boot/src/compose-stack.ts +++ b/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 } /** 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() + const declaredUnder = new Map() for (const layer of layers) { if (isContainedLayer(layer)) continue - visitPatchRows(layer.patches, (row, source) => { + let listed = new Set() + 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() @@ -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() for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName) + const userDisabledRowIds = new Set() 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, } } diff --git a/packages/boot/app-boot/src/external-bundles.ts b/packages/boot/app-boot/src/external-bundles.ts index 61197295ac..6cb2001f61 100644 --- a/packages/boot/app-boot/src/external-bundles.ts +++ b/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 - /** 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() + const declaredUnder = new Map() const duplicates: DuplicateRow[] = [] - const claim = (rowId: string, moduleName: string): void => { - if (rows.has(rowId)) duplicates.push({ rowId, moduleName }) - else rows.set(rowId, moduleName) - } - visitPatchRows(layer.patches, (row, source) => { - 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 (source === 'config') { - if (!rows.has(row.id)) rows.set(row.id, row.name) + const claim = (rowId: string, moduleName: string, target?: string): void => { + if (rows.has(rowId)) { + duplicates.push({ rowId, moduleName }) return } - claim(row.id, row.name) + rows.set(rowId, moduleName) + declaredUnder.set(rowId, target) + } + let listed = new Set() + 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() + } + // A root insert lands in the bundle's group: that is the group it declares under. + const target = place.target ?? groupId + if (source === 'config') { + // 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) + } + listed.add(row.id) }) claim(groupId, CONTAINED_GROUP_MODULE) const wrappers = new Map() @@ -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) diff --git a/packages/boot/app-boot/src/patch-rows.ts b/packages/boot/app-boot/src/patch-rows.ts index 0f227d20d6..ddc42a1545 100644 --- a/packages/boot/app-boot/src/patch-rows.ts +++ b/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 }) }) } - } + }) } diff --git a/packages/boot/app-boot/src/probe-child.ts b/packages/boot/app-boot/src/probe-child.ts index 2a0016b817..ca94965672 100644 --- a/packages/boot/app-boot/src/probe-child.ts +++ b/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 { @@ -31,7 +39,7 @@ async function inspect(specifier: string): Promise { } } -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() }) diff --git a/packages/boot/app-boot/src/probe-report.ts b/packages/boot/app-boot/src/probe-report.ts index e026df7f70..7e4fdc1869 100644 --- a/packages/boot/app-boot/src/probe-report.ts +++ b/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 diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index 4456c9cd2a..df26dfb39b 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -10,9 +10,11 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +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 { 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 + /* 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('close', (code) => { + clearTimeout(timer) + if (outcome !== undefined) { + outcome() return } - child.kill('SIGKILL') - settle(() => { resolve(report) }) - }) - 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()}`)) - }) + 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,8 +262,7 @@ export async function probePackage(options: ProbeOptions): Promise : 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() - const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis + const cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis()) const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library' @@ -275,13 +304,13 @@ export async function probePackage(options: ProbeOptions): Promise } /** - * 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. @@ -290,6 +319,53 @@ function resolveHarnessCordis(): string | undefined { } } +/** + * 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 } } +} + +/** + * 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. + */ +export function cordisPackageDir(resolved: string): string | undefined { + let dir = dirname(fileURLToPath(resolved)) + for (;;) { + 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) + } + } + const parent = dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + /** The cache file for one package under a profile. */ function probeCachePath(profileDir: string, packageName: string): string { return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`) diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index e5d35c9fa9..1ba0c4c191 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/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 { - const ids = new Set() - 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 { + 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): 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 +} diff --git a/packages/boot/app-boot/tests/compose-stack.spec.ts b/packages/boot/app-boot/tests/compose-stack.spec.ts index b0971025c5..fd8edcbe1e 100644 --- a/packages/boot/app-boot/tests/compose-stack.spec.ts +++ b/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], [ diff --git a/packages/boot/app-boot/tests/external-bundles.spec.ts b/packages/boot/app-boot/tests/external-bundles.spec.ts index aab736f251..43b39f5fa3 100644 --- a/packages/boot/app-boot/tests/external-bundles.spec.ts +++ b/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') diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index a46c395dc9..5745d44dd9 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -3,12 +3,12 @@ * a child process, and the per-profile cache. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' -import { parseProbeRecord } from '../src/probe.ts' +import { cordisPackageDir, parseProbeRecord } from '../src/probe.ts' import { parseChildReport, type ChildReport } from '../src/probe-report.ts' const NAME = 'dsh-test-bin' @@ -141,6 +141,46 @@ describe('probePackage', () => { expect(own.reason).toContain('own copy of @deepseek-ai/cordis') }) + 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 () => { const { profileDir, installAnchor } = stage({ 'minimal': { manifest: { version: undefined } }, @@ -175,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 () => { @@ -182,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' })) @@ -248,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[] = [ - { 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() diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index 461da92956..10d7fe9ff5 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/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 }> { 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( + (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 () => { diff --git a/packages/boot/plugin-manager/src/view.ts b/packages/boot/plugin-manager/src/view.ts index 6ed89d36ea..5b0718e19b 100644 --- a/packages/boot/plugin-manager/src/view.ts +++ b/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() 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 } }, }) diff --git a/packages/boot/plugin-manager/tests/plugin-manager.spec.ts b/packages/boot/plugin-manager/tests/plugin-manager.spec.ts index 4e5ef782f7..9c9baa9480 100644 --- a/packages/boot/plugin-manager/tests/plugin-manager.spec.ts +++ b/packages/boot/plugin-manager/tests/plugin-manager.spec.ts @@ -218,7 +218,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[] = [] diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3fa5b5ffb1..25157f4632 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/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', - 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', + 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', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index e687568daf..7c6255f0b1 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/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: d352f4749406b3402718cbfb01e0ca588e813dd0 -README.zh.md: 9cf530aec7824e5e3d7e9d62e4ca6b7e83808378 +README.md: 6159af2998c1e9130676bb28c75b1fb2e5d0df47 +README.zh.md: 5dd6535278d3803b25d70b3ae71d8f133cb5e5f0 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index d352f47494..6159af2998 100644 --- a/packages/host/plugin-inventory/README.md +++ b/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 diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 9cf530aec7..5dd6535278 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/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。 ### 每个预设的组合 diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index e0108aa741..df3a1fbae5 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/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() const listed = new Set() 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 } }) }, diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 2f9a09f99a..a2a808f0eb 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/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' diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 6919829f70..48428f4735 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/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', { diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index bb5f574ccb..1a02b44cfe 100644 --- a/packages/subprocess/subprocess/package.json +++ b/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:^", diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index d31b3685e4..d3e527241a 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/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 diff --git a/packages/util/launch-environment/README.i18n.yaml b/packages/util/launch-environment/README.i18n.yaml index 83cdcd7d55..0d56528bae 100644 --- a/packages/util/launch-environment/README.i18n.yaml +++ b/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 diff --git a/packages/util/launch-environment/README.md b/packages/util/launch-environment/README.md index 312952f527..0bb0bba6f5 100644 --- a/packages/util/launch-environment/README.md +++ b/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. + ----- diff --git a/packages/util/launch-environment/README.zh.md b/packages/util/launch-environment/README.zh.md index 3071052c94..d5707d7a35 100644 --- a/packages/util/launch-environment/README.zh.md +++ b/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` 按同一模式清洗每条被生成的命令。 + ----- diff --git a/packages/util/launch-environment/src/index.ts b/packages/util/launch-environment/src/index.ts index b12d936666..9b83eff96d 100644 --- a/packages/util/launch-environment/src/index.ts +++ b/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 { + const scrubbed: Record = {} + for (const [name, value] of Object.entries(env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(name)) scrubbed[name] = value + } + return scrubbed +} diff --git a/packages/util/launch-environment/tests/launch-environment.spec.ts b/packages/util/launch-environment/tests/launch-environment.spec.ts index 973b86357d..72eb02a50a 100644 --- a/packages/util/launch-environment/tests/launch-environment.spec.ts +++ b/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' } }, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0d981361c9..58d715be8b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -664,6 +664,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Entry', 'EntryTree', 'Exclude', 'Extract', @@ -676,6 +677,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Record', 'Readonly', 'Set', + 'ReadonlySet', 'Uint8Array', ])