Merge branch 'feat/plugin-mgmt-2-manager' into feat/plugin-mgmt-3-settings
This commit is contained in:
commit
16464f5147
41 files changed
+620
-176
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
|
||||
2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c0ae61e1323de3dcbeec826363dc953c32dbdcc5
|
||||
2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: d4e136e36dcf90ad78b499123bfe56add868484d
|
||||
2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 2b892cacf14a9f62921b0fce015f14970a2b5a06
|
||||
2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: f7004f83f417282670fb90effadf257716066e67
|
||||
+2
-2
@@ -8,7 +8,7 @@ English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md)
|
||||
|
||||
`installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place.
|
||||
|
||||
Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
|
||||
Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy (judged by package directory, through links and a packaged executable's proxy) or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -16,7 +16,7 @@ Separately, nothing could say what an installed package was without importing it
|
||||
|
||||
**Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
|
||||
|
||||
**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: an unrecognized report is a rejection, an unrecognized record is probed again. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
|
||||
**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: a message that does not echo the run's token is the package's own, not the report, and an unrecognized record is probed again. The child gets the parent environment minus credential-shaped names, removes the token and `process.send` before importing, keeps at most 16 KiB of stderr for the failure text, and is awaited to `close` after the kill so nothing of it outlives the call. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -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/` 下,按版本失效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -313,10 +313,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
loadProfile: () => prepareProfile(options.profile),
|
||||
compose: composeFor,
|
||||
rootEntry: () => rootIncludeEntry(ctx),
|
||||
readUserPatches: () => [
|
||||
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
],
|
||||
})
|
||||
const runtime = ctx.get('profileRuntime')
|
||||
if (runtime !== undefined) app.runtime = runtime
|
||||
|
||||
@@ -2,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
|
||||
@@ -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) |
|
||||
|
||||
@@ -396,6 +396,7 @@ flowchart TD
|
||||
pkg_storage_json --> pkg_storage
|
||||
pkg_storage_sqlite --> pkg_storage
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_subprocess --> pkg_launch_environment
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_session --> pkg_scope
|
||||
pkg_system_prompt --> pkg_invariants
|
||||
@@ -1276,7 +1277,7 @@ flowchart TD
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
|
||||
core.md: b7a1975d18f93eab6c67d7c85852c4ca59593188
|
||||
core.zh.md: 0fb0ed4e0470c760ec2d78ae13ff763dcd925f80
|
||||
core.md: f257eff317c2c68c9c8aca5145b3b7a4a9773fe3
|
||||
core.zh.md: 854876faa6c9d02b5dbde0cdf43ad8336a47b8f9
|
||||
+16
-6
@@ -960,13 +960,23 @@ Facts and recomposition of the booted profile.
|
||||
originOf(rowId: string): RowOrigin | undefined
|
||||
|
||||
/**
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`.
|
||||
* A `!!js` gate in a user file stays an expression node when read from
|
||||
* disk, so it is a condition, not a user decision, and is left to the
|
||||
* composition.
|
||||
* @returns the ids, re-read from disk on every call.
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`,
|
||||
* as the committed composition read them. The set describes the running
|
||||
* tree: a user file the include rejected, or one that cannot be parsed,
|
||||
* changes nothing here until a composition with it is accepted.
|
||||
* @returns the ids, from the committed composition.
|
||||
*/
|
||||
userDisabledRowIds(): Set<string>
|
||||
userDisabledRowIds(): ReadonlySet<string>
|
||||
|
||||
/**
|
||||
* Whether the user patch layers disable an entry: its own row id, or the
|
||||
* id of a group holding it, is among {@link userDisabledRowIds}. The Loader
|
||||
* disables every descendant of a disabled group, so a child's own id alone
|
||||
* does not say who switched it off.
|
||||
* @param entry - the Loader entry.
|
||||
* @returns true when the user's patches disable the entry or one of the groups holding it.
|
||||
*/
|
||||
userDisables(entry: Entry): boolean
|
||||
|
||||
/**
|
||||
* Recompose the host tree from the profile's layers and the user patch files
|
||||
|
||||
@@ -970,13 +970,23 @@ Facts and recomposition of the booted profile.
|
||||
originOf(rowId: string): RowOrigin | undefined
|
||||
|
||||
/**
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`.
|
||||
* A `!!js` gate in a user file stays an expression node when read from
|
||||
* disk, so it is a condition, not a user decision, and is left to the
|
||||
* composition.
|
||||
* @returns the ids, re-read from disk on every call.
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`,
|
||||
* as the committed composition read them. The set describes the running
|
||||
* tree: a user file the include rejected, or one that cannot be parsed,
|
||||
* changes nothing here until a composition with it is accepted.
|
||||
* @returns the ids, from the committed composition.
|
||||
*/
|
||||
userDisabledRowIds(): Set<string>
|
||||
userDisabledRowIds(): ReadonlySet<string>
|
||||
|
||||
/**
|
||||
* Whether the user patch layers disable an entry: its own row id, or the
|
||||
* id of a group holding it, is among {@link userDisabledRowIds}. The Loader
|
||||
* disables every descendant of a disabled group, so a child's own id alone
|
||||
* does not say who switched it off.
|
||||
* @param entry - the Loader entry.
|
||||
* @returns true when the user's patches disable the entry or one of the groups holding it.
|
||||
*/
|
||||
userDisables(entry: Entry): boolean
|
||||
|
||||
/**
|
||||
* Recompose the host tree from the profile's layers and the user patch files
|
||||
|
||||
@@ -2,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
|
||||
@@ -117,9 +117,9 @@ This section explains how the outcomes above are realized and points at the code
|
||||
- **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
|
||||
- **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
|
||||
- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that updates drops the records of rows it no longer configures and one that unmounts drops them all, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
|
||||
- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
|
||||
- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. A config override may restate a row under the group that already holds it; the same id twice in one config list, or set under another group, counts as declared twice — the Loader would reject the first at mount and silently move the second — so a built-in layer fails the boot and a contained bundle is left out. Boot, live recomposition, and `--dump-config` compose through the same function.
|
||||
- **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
|
||||
- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
|
||||
- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason — the cordis check compares package directories, through links and through the proxy a packaged executable writes, so `src` and `lib` resolutions of one package agree; the child's report and the cached record are validated field by field before either is trusted. The child runs with the harness's credential-shaped variables scrubbed (`SENSITIVE_ENV_PATTERN` from `dsh-launch-environment`), its report is the one message that echoes a per-run token it removed from its environment before importing — the imported code finds no `process.send` either — the failure text keeps the last 16 KiB of its stderr, and the probe settles only once the child closed. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
|
||||
- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
|
||||
- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
|
||||
- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
|
||||
@@ -195,6 +195,7 @@ These limits describe when this boot library is a poor fit or needs special care
|
||||
- **Patch edits merge at the key level, not the row level** — `setRowField('x', 'config', value)` replaces the whole `config` mapping of that patch; a caller that wants one nested field changed reads the current value with `rowField` and writes the merged mapping back.
|
||||
- **The patch writer authors no expressions** — it emits plain data only; a `!!js` gate is something an author types into the file, never something an API call produces.
|
||||
- **Lock orphans are an operator action** — a lock file left by a crashed patch writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice.
|
||||
- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -117,9 +117,9 @@ await mutatePatchFile(file, (document) => {
|
||||
- **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
|
||||
- **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
|
||||
- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
|
||||
- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
|
||||
- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。config 覆盖可以在已持有某行的组下重述它;同一 config 列表里出现两次、或改放到别的组下,都算声明了两次——前者会在挂载时被 Loader 拒绝,后者会被静默挪走——因此内置层启动失败、受控组合包整层排除。启动、运行时重组与 `--dump-config` 走同一个函数。
|
||||
- **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
|
||||
- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
|
||||
- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录——cordis 判定比较的是包目录,穿过符号链接与打包可执行文件写出的代理包,因此同一个包的 `src` 与 `lib` 解析结果一致;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`dsh-launch-environment` 的 `SENSITIVE_ENV_PATTERN`),报告是唯一回显本次 token 的那条消息,token 在 import 之前就从子进程环境里删掉、被 import 的代码也找不到 `process.send`;失败文本只留 stderr 的最后 16 KiB;探针要等子进程关闭后才结算。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
|
||||
- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
|
||||
- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
|
||||
- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
|
||||
@@ -195,6 +195,7 @@ await mutatePatchFile(file, (document) => {
|
||||
- **补丁编辑按键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。
|
||||
- **补丁写入器不生成表达式**——它只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。
|
||||
- **锁孤儿由操作者处理**——补丁写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。
|
||||
- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -58,6 +58,12 @@ export interface ComposedStack {
|
||||
readonly conflicts: RowConflict[]
|
||||
/** External bundles left out because a row id was already claimed or repeated. */
|
||||
readonly skippedBundles: string[]
|
||||
/**
|
||||
* Row ids the user layers disable with a literal `disabled: true`, as
|
||||
* composed; a `!!js` gate stays an expression node when read from disk, so
|
||||
* it is a condition of the composition, not a user decision.
|
||||
*/
|
||||
readonly userDisabledRowIds: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
|
||||
@@ -93,29 +99,44 @@ function rowIds(row: EntryOptions): string[] {
|
||||
* rows it inserts and the rows its config overrides set as a group's
|
||||
* children. Built-in and boot-staged layers claim first, in manifest order;
|
||||
* an id two of them declare, or one inserts twice, is a defect of the shipped
|
||||
* composition and throws, while a config override restating a row the same
|
||||
* layer declared is that layer keeping its own child. Contained external
|
||||
* layers then claim in manifest order, each rendered once here; one whose id
|
||||
* is already owned, or which declares an id twice, is left out whole.
|
||||
* composition and throws, while a config override restating a row under the
|
||||
* group the same layer declared it in is that layer keeping its own child;
|
||||
* the same id twice in one config list, or set under another group, is
|
||||
* declared twice. Contained external layers then claim in manifest order,
|
||||
* each rendered once here; one whose id is already owned, or which declares
|
||||
* an id twice, is left out whole.
|
||||
* @param layers - the profile's bundle layers, in manifest order.
|
||||
* @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
|
||||
* @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
|
||||
*/
|
||||
export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
|
||||
const owners = new Map<string, ProfileLayer>()
|
||||
const declaredUnder = new Map<string, string | undefined>()
|
||||
for (const layer of layers) {
|
||||
if (isContainedLayer(layer)) continue
|
||||
visitPatchRows(layer.patches, (row, source) => {
|
||||
let listed = new Set<string>()
|
||||
let listIndex = -1
|
||||
visitPatchRows(layer.patches, (row, source, place) => {
|
||||
if (typeof row.id !== 'string') return
|
||||
if (place.patch !== listIndex) {
|
||||
listIndex = place.patch
|
||||
listed = new Set()
|
||||
}
|
||||
const owner = owners.get(row.id)
|
||||
if (owner === layer) {
|
||||
if (source === 'insert') throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
|
||||
return
|
||||
}
|
||||
if (owner !== undefined) {
|
||||
// Restating a row under the group that already holds it is the layer
|
||||
// keeping its child; inserting it again, listing it twice in one
|
||||
// config, or setting it under another group is declaring it twice.
|
||||
if (source === 'insert' || listed.has(row.id) || declaredUnder.get(row.id) !== place.target) {
|
||||
throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
|
||||
}
|
||||
} else if (owner !== undefined) {
|
||||
throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
|
||||
} else {
|
||||
owners.set(row.id, layer)
|
||||
declaredUnder.set(row.id, place.target)
|
||||
}
|
||||
owners.set(row.id, layer)
|
||||
listed.add(row.id)
|
||||
})
|
||||
}
|
||||
const skipped = new Map<string, RowConflict[]>()
|
||||
@@ -151,7 +172,7 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
|
||||
* @param binName - the diagnostic prefix on a thrown built-in duplicate.
|
||||
* @param layers - the profile's bundle layers, in manifest order.
|
||||
* @param userLayers - the user-owned layers, in application order.
|
||||
* @returns the patches to mount, the owner of every bundle id, the conflicts, and the bundles left out.
|
||||
* @returns the patches to mount, the owner of every bundle id, the conflicts, the bundles left out, and the rows the user layers disable.
|
||||
* @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
|
||||
*/
|
||||
export function composeProfileStack(
|
||||
@@ -178,10 +199,12 @@ export function composeProfileStack(
|
||||
}
|
||||
const claimed = new Map<string, string>()
|
||||
for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
|
||||
const userDisabledRowIds = new Set<string>()
|
||||
for (const userLayer of userLayers) {
|
||||
const patches: PatchOptions[] = []
|
||||
for (const patch of userLayer.patches) {
|
||||
if (patch.insert === undefined) {
|
||||
if (patch.id !== undefined && patch.disabled === true) userDisabledRowIds.add(patch.id)
|
||||
patches.push(patch)
|
||||
continue
|
||||
}
|
||||
@@ -207,6 +230,7 @@ export function composeProfileStack(
|
||||
owners: ownership.owners,
|
||||
conflicts,
|
||||
skippedBundles,
|
||||
userDisabledRowIds,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,11 @@ export interface ComposedExternalLayer {
|
||||
* names; a repeated id keeps its first module.
|
||||
*/
|
||||
rows: Map<string, string>
|
||||
/** Ids the layer introduces more than once, in order of repetition; a config override restating a row is not one. */
|
||||
/**
|
||||
* Ids the layer introduces more than once, in order of repetition. A config
|
||||
* override restating a row under the group that already holds it is not
|
||||
* one; the same id twice in one config list, or set under another group, is.
|
||||
*/
|
||||
duplicates: DuplicateRow[]
|
||||
/** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
|
||||
overrides: string[]
|
||||
@@ -87,7 +91,9 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
|
||||
* an override when it addresses a row the bundle did not introduce; the rows
|
||||
* it sets as a group's config count as the bundle's own, since they mount
|
||||
* as children like inserted ones. Every id, declared or generated, goes
|
||||
* through one registration, so a row spelling a wrapper's id is a duplicate.
|
||||
* through one registration, so a row spelling a wrapper's id is a duplicate,
|
||||
* and so is a config row the bundle already declared under another group,
|
||||
* or listed twice: the Loader would move the first and reject the second.
|
||||
* Ids are indexed before any patch is emitted, so an insert into a group the
|
||||
* bundle introduces later in its list still counts as its own.
|
||||
* @param layer - the resolved external layer.
|
||||
@@ -96,20 +102,40 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
|
||||
export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
|
||||
const groupId = bundleGroupId(layer.packageName)
|
||||
const rows = new Map<string, string>()
|
||||
const declaredUnder = new Map<string, string | undefined>()
|
||||
const duplicates: DuplicateRow[] = []
|
||||
const claim = (rowId: string, moduleName: string): void => {
|
||||
if (rows.has(rowId)) duplicates.push({ rowId, moduleName })
|
||||
else rows.set(rowId, moduleName)
|
||||
}
|
||||
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<string>()
|
||||
let listIndex = -1
|
||||
visitPatchRows(layer.patches, (row, source, place) => {
|
||||
if (typeof row.id !== 'string') return
|
||||
if (place.patch !== listIndex) {
|
||||
listIndex = place.patch
|
||||
listed = new Set()
|
||||
}
|
||||
// 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<string, string>()
|
||||
@@ -176,7 +202,7 @@ export interface BundleReconciliation {
|
||||
removed: string[]
|
||||
/** Newly added dependencies that declare no `dsh.bundle` (plain libraries or plugin modules). */
|
||||
plain: string[]
|
||||
/** Installed bundles left out of the layer list because `autoEnable` was off. */
|
||||
/** Bundles the run installed and left out of the layer list because `autoEnable` was off. */
|
||||
installedOnly: string[]
|
||||
}
|
||||
|
||||
@@ -184,9 +210,11 @@ export interface BundleReconciliation {
|
||||
* Reconcile `dsh.profile.bundles` against the installed state after a pnpm
|
||||
* run. A dependency that no longer resolves to a bundle leaves the layer
|
||||
* list; template bundles (never dependencies) are untouched. A dependency
|
||||
* that resolves to a bundle joins the list only when `autoEnable` is set —
|
||||
* the CLI's install-and-enable semantics — and is otherwise reported as
|
||||
* installed-only, which is the plugin manager's install step.
|
||||
* the run added that resolves to a bundle joins the list only when
|
||||
* `autoEnable` is set — the CLI's install-and-enable semantics — and is
|
||||
* otherwise reported as installed-only, which is the plugin manager's install
|
||||
* step. A bundle installed before the run keeps its place in or out of the
|
||||
* list: one the user disabled stays disabled through an unrelated run.
|
||||
* @param binName - the diagnostic prefix used by manifest reads.
|
||||
* @param profileDir - the profile directory.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json.
|
||||
@@ -207,16 +235,17 @@ export function reconcileInstalledBundles(
|
||||
const bundles = [...after.dsh?.profile?.bundles ?? []]
|
||||
const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] }
|
||||
for (const packageName of dependencies) {
|
||||
const isBundle = exportsBundlePatch(binName, packageName, installAnchor, profileDir)
|
||||
if (isBundle && !bundles.includes(packageName)) {
|
||||
if (options.autoEnable) {
|
||||
bundles.push(packageName)
|
||||
outcome.enabled.push(packageName)
|
||||
} else {
|
||||
outcome.installedOnly.push(packageName)
|
||||
}
|
||||
} else if (!isBundle && !beforeDeps.has(packageName)) {
|
||||
if (beforeDeps.has(packageName)) continue
|
||||
if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) {
|
||||
outcome.plain.push(packageName)
|
||||
continue
|
||||
}
|
||||
if (bundles.includes(packageName)) continue
|
||||
if (options.autoEnable) {
|
||||
bundles.push(packageName)
|
||||
outcome.enabled.push(packageName)
|
||||
} else {
|
||||
outcome.installedOnly.push(packageName)
|
||||
}
|
||||
}
|
||||
const dependencySet = new Set(dependencies)
|
||||
|
||||
@@ -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 }) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
* over the IPC channel the parent opened. stdout and stderr stay the
|
||||
* imported modules' own, so a package that prints at import still reports.
|
||||
* Arguments: the package directory, the main specifier (empty for none), and
|
||||
* the addable specifiers as a JSON array. Nothing here runs inside the host.
|
||||
* the addable specifiers as a JSON array; the environment carries the token
|
||||
* the report echoes. Nothing here runs inside the host.
|
||||
* @module @deepseek-ai/dsh-app-boot/probe-child
|
||||
*/
|
||||
|
||||
@@ -13,6 +14,13 @@ import type { ChildInspection, ChildReport } from './probe-report.ts'
|
||||
|
||||
const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2)
|
||||
const base = pathToFileURL(`${dir}/package.json`).href
|
||||
// The report is the parent's to receive: its token leaves the environment and
|
||||
// the channel's `send` leaves `process` before any of the package's code runs,
|
||||
// so nothing that code sends at import can pass as the report.
|
||||
const token = process.env.DSH_PROBE_REPORT ?? ''
|
||||
delete process.env.DSH_PROBE_REPORT
|
||||
const send = process.send?.bind(process)
|
||||
Reflect.deleteProperty(process, 'send')
|
||||
|
||||
/** Import one module from the package and describe what it exports. */
|
||||
async function inspect(specifier: string): Promise<ChildInspection> {
|
||||
@@ -31,7 +39,7 @@ async function inspect(specifier: string): Promise<ChildInspection> {
|
||||
}
|
||||
}
|
||||
|
||||
const report: ChildReport = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
|
||||
const report: ChildReport = { token, cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
|
||||
try {
|
||||
report.cordis = import.meta.resolve('@deepseek-ai/cordis', base)
|
||||
} catch {
|
||||
@@ -39,4 +47,4 @@ try {
|
||||
}
|
||||
if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
|
||||
for (const name of JSON.parse(addableJson) as string[]) report.addable[name] = await inspect(name)
|
||||
process.send?.(report, undefined, undefined, () => { process.disconnect() })
|
||||
send?.(report, undefined, undefined, () => { process.disconnect() })
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChildReport> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = randomUUID()
|
||||
const child = spawn(
|
||||
options.nodeExecutable ?? process.execPath,
|
||||
['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)],
|
||||
{ cwd: options.profileDir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
|
||||
{
|
||||
cwd: options.profileDir,
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token },
|
||||
},
|
||||
)
|
||||
// The tail of stderr, for the failure text: a package that floods stderr at
|
||||
// import must not grow this process's heap by as much.
|
||||
const err: Buffer[] = []
|
||||
child.stderr?.on('data', (chunk: Buffer) => err.push(chunk))
|
||||
// One settlement: a spawn failure emits `error` and then `close`, a
|
||||
// timeout kill emits `close` after the rejection below, and the kill
|
||||
// after a report emits `close` after the resolution.
|
||||
let settled = false
|
||||
let unrecognized = false
|
||||
const settle = (outcome: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
let errBytes = 0
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
err.push(chunk)
|
||||
errBytes += chunk.length
|
||||
while (errBytes > STDERR_TAIL_BYTES && err.length > 1) errBytes -= (err.shift() as Buffer).length
|
||||
if (errBytes > STDERR_TAIL_BYTES) {
|
||||
err[0] = (err[0] as Buffer).subarray(errBytes - STDERR_TAIL_BYTES)
|
||||
errBytes = STDERR_TAIL_BYTES
|
||||
}
|
||||
})
|
||||
// The outcome lands on `close`. A kill — after the report, or at the
|
||||
// timeout — records its outcome and waits for the close it causes, so the
|
||||
// child's pipes and channel are gone when the caller continues; a spawn
|
||||
// failure emits `error` with no process to wait for.
|
||||
let outcome: (() => void) | undefined
|
||||
const finish = (next: () => void): void => {
|
||||
/* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */
|
||||
if (outcome !== undefined) return
|
||||
outcome = next
|
||||
clearTimeout(timer)
|
||||
outcome()
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
|
||||
finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
|
||||
}, timeoutMs)
|
||||
child.on('message', (message) => {
|
||||
const report = parseChildReport(message)
|
||||
if (report === undefined) {
|
||||
unrecognized = true
|
||||
/* 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<PluginProbe>
|
||||
: describeBundlePatch(options.binName, join(packageDir, declaredBundle))
|
||||
const hasMain = manifest.main !== undefined || manifest.exports !== undefined
|
||||
const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name))
|
||||
const harnessCordis = resolveHarnessCordis()
|
||||
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<PluginProbe>
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy of `@deepseek-ai/cordis` this harness runs: the one this module
|
||||
* resolves, which is the one every built-in plugin shares. Undefined only in
|
||||
* an environment that cannot resolve it at all.
|
||||
* The package directory of the `@deepseek-ai/cordis` copy this harness runs:
|
||||
* the one this module resolves, which every built-in plugin shares. Undefined
|
||||
* only in an environment that cannot resolve it at all.
|
||||
*/
|
||||
function resolveHarnessCordis(): string | undefined {
|
||||
try {
|
||||
return import.meta.resolve('@deepseek-ai/cordis')
|
||||
return cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
|
||||
} catch {
|
||||
// Only an embedder without cordis on its module path lands here; the
|
||||
// harness itself always resolves its own peer.
|
||||
@@ -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<string, string> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory of the `@deepseek-ai/cordis` package a resolved module URL
|
||||
* belongs to, with symlinks resolved and a dsh module proxy followed to the
|
||||
* package it re-exports. Two resolutions of one installed package compare
|
||||
* equal this way whether they landed on `src` or `lib`, on a link or its
|
||||
* target, or on the proxy a packaged executable writes for its peers; the
|
||||
* URLs themselves differ in every one of those cases.
|
||||
* @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave.
|
||||
* @returns the real package directory, or undefined when no cordis manifest encloses the URL.
|
||||
*/
|
||||
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`)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
|
||||
import type { ComposedStack, RowConflict } from './compose-stack.ts'
|
||||
import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
|
||||
@@ -54,8 +53,6 @@ export interface ProfileRuntimeOptions {
|
||||
compose: (profile: Profile) => ComposedStack
|
||||
/** The root Include entry, once mounted. */
|
||||
rootEntry: () => Entry | undefined
|
||||
/** The user patch layers as they stand on disk (profile file, then home file). */
|
||||
readUserPatches: () => PatchOptions[]
|
||||
}
|
||||
|
||||
/** The profile and stack the tree runs, published together once the root include accepted the stack. */
|
||||
@@ -131,19 +128,26 @@ export class ProfileRuntime extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`.
|
||||
* A `!!js` gate in a user file stays an expression node when read from
|
||||
* disk, so it is a condition, not a user decision, and is left to the
|
||||
* composition.
|
||||
* @returns the ids, re-read from disk on every call.
|
||||
* Row ids the user patch layers disable with a literal `disabled: true`,
|
||||
* as the committed composition read them. The set describes the running
|
||||
* tree: a user file the include rejected, or one that cannot be parsed,
|
||||
* changes nothing here until a composition with it is accepted.
|
||||
* @returns the ids, from the committed composition.
|
||||
*/
|
||||
userDisabledRowIds(): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const patch of this.options.readUserPatches()) {
|
||||
if (patch.insert !== undefined || patch.id === undefined) continue
|
||||
if (patch.disabled === true) ids.add(patch.id)
|
||||
}
|
||||
return ids
|
||||
userDisabledRowIds(): ReadonlySet<string> {
|
||||
return this.committed.stack.userDisabledRowIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user patch layers disable an entry: its own row id, or the
|
||||
* id of a group holding it, is among {@link userDisabledRowIds}. The Loader
|
||||
* disables every descendant of a disabled group, so a child's own id alone
|
||||
* does not say who switched it off.
|
||||
* @param entry - the Loader entry.
|
||||
* @returns true when the user's patches disable the entry or one of the groups holding it.
|
||||
*/
|
||||
userDisables(entry: Entry): boolean {
|
||||
return userDisablesEntry(entry, this.committed.stack.userDisabledRowIds)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,3 +187,11 @@ export class ProfileRuntime extends Service {
|
||||
this.committed = { profile, stack }
|
||||
}
|
||||
}
|
||||
|
||||
/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */
|
||||
function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet<string>): boolean {
|
||||
for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) {
|
||||
if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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], [
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown>[] = [
|
||||
{ cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
|
||||
{ token: undefined }, { token: 1 }, { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
|
||||
{ main: { ok: true, isPlugin: true } }, { main: { ...inspection, error: 1 } }, { addable: [] }, { addable: { 'pkg/x': 1 } },
|
||||
]
|
||||
for (const fields of broken) expect(parseChildReport({ ...report, ...fields }), JSON.stringify(fields)).toBeUndefined()
|
||||
|
||||
@@ -24,7 +24,7 @@ function profile(layers: ProfileLayer[]): Profile {
|
||||
|
||||
async function harness(
|
||||
layers: ProfileLayer[],
|
||||
options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
|
||||
options: { rootEntry?: () => Entry | undefined; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
|
||||
): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
@@ -37,6 +37,7 @@ async function harness(
|
||||
owners: claimLayerIds(current.layers).owners,
|
||||
conflicts: current === options.reloaded ? options.conflicts ?? [] : [],
|
||||
skippedBundles: [],
|
||||
userDisabledRowIds: new Set(current === options.reloaded ? ['reloaded-off'] : ['booted-off']),
|
||||
}
|
||||
})
|
||||
const booted = profile(layers)
|
||||
@@ -47,7 +48,6 @@ async function harness(
|
||||
loadProfile: () => options.reloaded ?? profile(layers),
|
||||
compose,
|
||||
rootEntry: options.rootEntry ?? (() => undefined),
|
||||
readUserPatches: () => options.userPatches ?? [],
|
||||
})
|
||||
return { ctx, runtime: ctx.profileRuntime, compose }
|
||||
}
|
||||
@@ -93,16 +93,24 @@ describe('ProfileRuntime', () => {
|
||||
expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' })
|
||||
})
|
||||
|
||||
it('reads user-disabled rows from literal disabled: true items only', async () => {
|
||||
const { runtime } = await harness([], {
|
||||
userPatches: [
|
||||
{ id: 'a', disabled: true },
|
||||
{ id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
|
||||
{ id: 'c', config: {} },
|
||||
{ insert: [{ id: 'd', name: 'x', disabled: true }] },
|
||||
],
|
||||
})
|
||||
expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
|
||||
it('reports the user-disabled rows of the committed composition and keeps them when the include rejects an update', async () => {
|
||||
const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update: vi.fn(async () => { throw new Error('rejected') }) } as unknown as Entry
|
||||
const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
|
||||
const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
|
||||
expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
|
||||
await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
|
||||
expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
|
||||
})
|
||||
|
||||
it('tells a row the user disabled through a group holding it from one the composition gates', async () => {
|
||||
const { runtime } = await harness([layer('a', 'builtin', [])])
|
||||
const chain = (ids: string[]): Entry => ids.reduceRight<Entry | undefined>(
|
||||
(parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined,
|
||||
) as Entry
|
||||
// The booted composition disables 'booted-off'.
|
||||
expect(runtime.userDisables(chain(['kid', 'booted-off', 'root']))).toBe(true)
|
||||
expect(runtime.userDisables(chain(['booted-off', 'grp']))).toBe(true)
|
||||
expect(runtime.userDisables(chain(['kid', 'grp']))).toBe(false)
|
||||
})
|
||||
|
||||
it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
|
||||
@@ -120,9 +128,10 @@ describe('ProfileRuntime', () => {
|
||||
await runtime.recompose({ reloadBundles: true })
|
||||
expect(runtime.layers).toHaveLength(2)
|
||||
expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
|
||||
// Provenance and conflicts follow the reloaded profile once the update holds.
|
||||
// Provenance, conflicts, and the user-disabled rows follow the reloaded profile once the update holds.
|
||||
expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
|
||||
expect(runtime.conflicts).toEqual(conflicts)
|
||||
expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
|
||||
})
|
||||
|
||||
it('runs recompositions one at a time, each from what the previous one committed', async () => {
|
||||
|
||||
@@ -129,7 +129,6 @@ function packageStatus(facts: {
|
||||
/** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
|
||||
function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
|
||||
const failures = ctx.get('pluginFailures')
|
||||
const userDisabled = runtime.userDisabledRowIds()
|
||||
const rows: PluginPackageRowView[] = []
|
||||
const listed = new Set<string>()
|
||||
for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) {
|
||||
@@ -140,7 +139,8 @@ function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): Plug
|
||||
rowId,
|
||||
moduleName: entry.options.name,
|
||||
enabled: !entry.disabled,
|
||||
...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
|
||||
// The runtime walks the groups holding the entry: a row inside a group the user disabled is the user's doing.
|
||||
...entry.disabled ? { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const } : {},
|
||||
phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
|
||||
...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
|
||||
})
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -1476,10 +1476,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
|
||||
},
|
||||
{
|
||||
signature: 'userDisabledRowIds(): Set<string>',
|
||||
description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file stays an expression node when read from disk, so it is a condition, not a user decision, and is left to the composition.',
|
||||
signature: 'userDisabledRowIds(): ReadonlySet<string>',
|
||||
description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.',
|
||||
parameters: [],
|
||||
returns: 'the ids, re-read from disk on every call.',
|
||||
returns: 'the ids, from the committed composition.',
|
||||
},
|
||||
{
|
||||
signature: 'userDisables(entry: Entry): boolean',
|
||||
description: 'Whether the user patch layers disable an entry: its own row id, or the id of a group holding it, is among userDisabledRowIds. The Loader disables every descendant of a disabled group, so a child\'s own id alone does not say who switched it off.',
|
||||
parameters: [{ name: 'entry', description: 'the Loader entry.' }],
|
||||
returns: 'true when the user\'s patches disable the entry or one of the groups holding it.',
|
||||
},
|
||||
{
|
||||
signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
|
||||
|
||||
@@ -2,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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
### 每个预设的组合
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
|
||||
const entries: PluginInventoryEntry[] = []
|
||||
const runtime = this.ctx.get('profileRuntime')
|
||||
const failures = this.ctx.get('pluginFailures')
|
||||
const userDisabled = runtime?.userDisabledRowIds() ?? new Set<string>()
|
||||
const listed = new Set<string>()
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
if (entry.options.group) continue
|
||||
@@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
|
||||
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
|
||||
trust: origin?.trust ?? 'builtin',
|
||||
...origin === undefined ? {} : { package: packageRef(origin) },
|
||||
...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const },
|
||||
...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
|
||||
...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
|
||||
})
|
||||
}
|
||||
@@ -125,7 +124,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
|
||||
moduleName: conflict.moduleName,
|
||||
enabled: true,
|
||||
fiberPhase: 'failed',
|
||||
trust: conflict.packageName === undefined ? 'builtin' : 'external',
|
||||
trust: conflict.packageName === undefined ? 'user' : 'external',
|
||||
...conflict.packageName === undefined
|
||||
? {}
|
||||
: { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
|
||||
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { DSH_ENV_PREFIX } from './types.ts'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
|
||||
@@ -38,11 +39,12 @@ export type {
|
||||
/**
|
||||
* Credential-shaped environment names are NOT forwarded to children (the
|
||||
* harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
|
||||
* process implicitly). One heuristic for every in-repo spawner; a
|
||||
* process implicitly). One heuristic for every in-repo spawner, defined in
|
||||
* `dsh-launch-environment` so the package probe scrubs by the same rule; a
|
||||
* deliberately supplied entry survives because explicit env layers merge
|
||||
* after the scrub.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
|
||||
export { SENSITIVE_ENV_PATTERN }
|
||||
|
||||
/**
|
||||
* The ambient parent environment minus credential-shaped names and minus all
|
||||
|
||||
@@ -2,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
|
||||
@@ -51,6 +51,10 @@ Names match the way the platform matches them: exactly on POSIX, case-insensitiv
|
||||
|
||||
`launchEnvironmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. The fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has is the environment it was launched with.
|
||||
|
||||
### Scrubbing credentials for a child
|
||||
|
||||
`withoutSensitiveEnv(process.env)` returns the environment without its credential-shaped entries — names matching `SENSITIVE_ENV_PATTERN` (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`, case-insensitively) — and without unset values, for a child that must not see the harness's credentials: the package probe in `dsh-app-boot` spawns with it, and `dsh-subprocess` scrubs every spawned command by the same pattern.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -51,6 +51,10 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
|
||||
|
||||
当产品 CLI 引导了这棵树时,`launchEnvironmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切就是它被启动时的环境。
|
||||
|
||||
### 为子进程剔除凭据
|
||||
|
||||
`withoutSensitiveEnv(process.env)` 返回去掉了密钥形态条目——名字匹配 `SENSITIVE_ENV_PATTERN`(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`,不分大小写)——且去掉了未设置值的环境,给不该看到 harness 凭据的子进程用:`dsh-app-boot` 的包探针用它生成子进程,`dsh-subprocess` 按同一模式清洗每条被生成的命令。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -122,3 +122,24 @@ declare module '@deepseek-ai/cordis' {
|
||||
launchEnvironment?: LaunchEnvironmentSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment names that carry credentials — API keys, passwords, secrets,
|
||||
* tokens — matched case-insensitively. The one rule every harness child spawn
|
||||
* scrubs by, so the harness's own credentials never reach a child implicitly.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
|
||||
|
||||
/**
|
||||
* An environment without its credential-shaped entries, for a child that must
|
||||
* not see the harness's credentials: a package probe, a spawned tool.
|
||||
* @param env - the environment to scrub, typically `process.env`.
|
||||
* @returns a fresh object holding every entry whose name is not credential-shaped and whose value is set.
|
||||
*/
|
||||
export function withoutSensitiveEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const scrubbed: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(name)) scrubbed[name] = value
|
||||
}
|
||||
return scrubbed
|
||||
}
|
||||
@@ -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' } },
|
||||
|
||||
@@ -664,6 +664,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'AsyncIterable',
|
||||
'Context',
|
||||
'Error',
|
||||
'Entry',
|
||||
'EntryTree',
|
||||
'Exclude',
|
||||
'Extract',
|
||||
@@ -676,6 +677,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Record',
|
||||
'Readonly',
|
||||
'Set',
|
||||
'ReadonlySet',
|
||||
'Uint8Array',
|
||||
])
|
||||
|
||||
|
||||
Reference in new issue
Block a user