Merge branch 'feat/plugin-mgmt-2-manager' into feat/plugin-mgmt-3-settings

This commit is contained in:
Yichen Jiang committed 2026-09-08 16:23:34 +08:00
commit 16464f5147
41 files changed
+620 -176

No files matched your search

@@ -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
@@ -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
@@ -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/` 下,按版本失效。
## 考虑过的替代方案
-4
View File
@@ -313,10 +313,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
loadProfile: () => prepareProfile(options.profile),
compose: composeFor,
rootEntry: () => rootIncludeEntry(ctx),
readUserPatches: () => [
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
],
})
const runtime = ctx.get('profileRuntime')
if (runtime !== undefined) app.runtime = runtime
+2 -2
View File
@@ -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
+2 -1
View File
@@ -394,6 +394,7 @@ flowchart TD
pkg_storage_json --> pkg_storage
pkg_storage_sqlite --> pkg_storage
pkg_subprocess --> pkg_http_proxy
pkg_subprocess --> pkg_launch_environment
pkg_typert_loader --> pkg_typert_registry
pkg_session --> pkg_scope
pkg_system_prompt --> pkg_invariants
@@ -1274,7 +1275,7 @@ flowchart TD
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
+2 -1
View File
@@ -396,6 +396,7 @@ flowchart TD
pkg_storage_json --> pkg_storage
pkg_storage_sqlite --> pkg_storage
pkg_subprocess --> pkg_http_proxy
pkg_subprocess --> pkg_launch_environment
pkg_typert_loader --> pkg_typert_registry
pkg_session --> pkg_scope
pkg_system_prompt --> pkg_invariants
@@ -1276,7 +1277,7 @@ flowchart TD
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
+2 -2
View File
@@ -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
View File
@@ -960,13 +960,23 @@ Facts and recomposition of the booted profile.
originOf(rowId: string): RowOrigin | undefined
/**
* Row ids the user patch layers disable with a literal `disabled: true`.
* A `!!js` gate in a user file stays an expression node when read from
* disk, so it is a condition, not a user decision, and is left to the
* composition.
* @returns the ids, re-read from disk on every call.
* Row ids the user patch layers disable with a literal `disabled: true`,
* as the committed composition read them. The set describes the running
* tree: a user file the include rejected, or one that cannot be parsed,
* changes nothing here until a composition with it is accepted.
* @returns the ids, from the committed composition.
*/
userDisabledRowIds(): Set<string>
userDisabledRowIds(): ReadonlySet<string>
/**
* Whether the user patch layers disable an entry: its own row id, or the
* id of a group holding it, is among {@link userDisabledRowIds}. The Loader
* disables every descendant of a disabled group, so a child's own id alone
* does not say who switched it off.
* @param entry - the Loader entry.
* @returns true when the user's patches disable the entry or one of the groups holding it.
*/
userDisables(entry: Entry): boolean
/**
* Recompose the host tree from the profile's layers and the user patch files
+16 -6
View File
@@ -970,13 +970,23 @@ Facts and recomposition of the booted profile.
originOf(rowId: string): RowOrigin | undefined
/**
* Row ids the user patch layers disable with a literal `disabled: true`.
* A `!!js` gate in a user file stays an expression node when read from
* disk, so it is a condition, not a user decision, and is left to the
* composition.
* @returns the ids, re-read from disk on every call.
* Row ids the user patch layers disable with a literal `disabled: true`,
* as the committed composition read them. The set describes the running
* tree: a user file the include rejected, or one that cannot be parsed,
* changes nothing here until a composition with it is accepted.
* @returns the ids, from the committed composition.
*/
userDisabledRowIds(): Set<string>
userDisabledRowIds(): ReadonlySet<string>
/**
* Whether the user patch layers disable an entry: its own row id, or the
* id of a group holding it, is among {@link userDisabledRowIds}. The Loader
* disables every descendant of a disabled group, so a child's own id alone
* does not say who switched it off.
* @param entry - the Loader entry.
* @returns true when the user's patches disable the entry or one of the groups holding it.
*/
userDisables(entry: Entry): boolean
/**
* Recompose the host tree from the profile's layers and the user patch files
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: ab734b967ddc734b9690e5186df5637a06cdfb8d
README.zh.md: e1214ec99162df0c2bc1daff6d1c182cba8b01f7
README.md: dbb3a8bc0e6b0a8c8face297b46bc7d80fcb03be
README.zh.md: e2e3cdc772157039256dea4a923abace36849527
+3 -2
View File
@@ -117,9 +117,9 @@ This section explains how the outcomes above are realized and points at the code
- **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
- **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that updates drops the records of rows it no longer configures and one that unmounts drops them all, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. A config override may restate a row under the group that already holds it; the same id twice in one config list, or set under another group, counts as declared twice — the Loader would reject the first at mount and silently move the second — so a built-in layer fails the boot and a contained bundle is left out. Boot, live recomposition, and `--dump-config` compose through the same function.
- **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason — the cordis check compares package directories, through links and through the proxy a packaged executable writes, so `src` and `lib` resolutions of one package agree; the child's report and the cached record are validated field by field before either is trusted. The child runs with the harness's credential-shaped variables scrubbed (`SENSITIVE_ENV_PATTERN` from `dsh-launch-environment`), its report is the one message that echoes a per-run token it removed from its environment before importing — the imported code finds no `process.send` either — the failure text keeps the last 16 KiB of its stderr, and the probe settles only once the child closed. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
@@ -195,6 +195,7 @@ These limits describe when this boot library is a poor fit or needs special care
- **Patch edits merge at the key level, not the row level** — `setRowField('x', 'config', value)` replaces the whole `config` mapping of that patch; a caller that wants one nested field changed reads the current value with `rowField` and writes the merged mapping back.
- **The patch writer authors no expressions** — it emits plain data only; a `!!js` gate is something an author types into the file, never something an API call produces.
- **Lock orphans are an operator action** — a lock file left by a crashed patch writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice.
- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are.
<a id="dev-note"></a>
### Dev Note
+3 -2
View File
@@ -117,9 +117,9 @@ await mutatePatchFile(file, (document) => {
- **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
- **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。config 覆盖可以在已持有某行的组下重述它;同一 config 列表里出现两次、或改放到别的组下,都算声明了两次——前者会在挂载时被 Loader 拒绝,后者会被静默挪走——因此内置层启动失败、受控组合包整层排除。启动、运行时重组与 `--dump-config` 走同一个函数。
- **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录——cordis 判定比较的是包目录,穿过符号链接与打包可执行文件写出的代理包,因此同一个包的 `src` 与 `lib` 解析结果一致;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`dsh-launch-environment` 的 `SENSITIVE_ENV_PATTERN`),报告是唯一回显本次 token 的那条消息,token 在 import 之前就从子进程环境里删掉、被 import 的代码也找不到 `process.send`;失败文本只留 stderr 的最后 16 KiB;探针要等子进程关闭后才结算。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
@@ -195,6 +195,7 @@ await mutatePatchFile(file, (document) => {
- **补丁编辑按键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。
- **补丁写入器不生成表达式**——它只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。
- **锁孤儿由操作者处理**——补丁写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。
- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。
<a id="dev-note"></a>
### 开发备注
+35 -11
View File
@@ -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,
}
}
+55 -26
View File
@@ -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)
+37 -10
View File
@@ -11,6 +11,22 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
/** How a patch introduced a row: inserted it, or set it as a group's `config`. */
export type PatchRowSource = 'insert' | 'config'
/**
* Where a patch introduced a row. `target` is the id of the group the row is
* declared under — the patch's target, or the enclosing group row for a nested
* one — undefined for a root-level insert, and a private marker for a child of
* a group without an id, which no later patch can address. `patch` is the
* index of the patch in the list, so the same id twice in one config list can
* be told from a restatement across patches.
*/
export interface PatchRowPlace {
readonly target: string | undefined
readonly patch: number
}
/** The target of rows nested in a group that declares no id: nothing can restate them, so nothing may match it. */
const ANONYMOUS_GROUP = '\u0000anonymous-group'
/** Whether a config item is a row: a plain object naming a module. */
function isRowLike(value: unknown): value is EntryOptions {
return typeof value === 'object' && value !== null && !Array.isArray(value) && typeof (value as { name?: unknown }).name === 'string'
@@ -23,9 +39,17 @@ function isRowLike(value: unknown): value is EntryOptions {
* @param visit - called once per row.
*/
export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => void): void {
visit(row)
visitPlacedRows(row, undefined, (entry) => { visit(entry) })
}
/** The walk behind {@link visitRowTree}, carrying the group each row is declared under. */
function visitPlacedRows(
row: EntryOptions, target: string | undefined, visit: (row: EntryOptions, target: string | undefined) => void,
): void {
visit(row, target)
if (row.group && Array.isArray(row.config)) {
for (const child of row.config as EntryOptions[]) visitRowTree(child, visit)
const own = typeof row.id === 'string' ? row.id : ANONYMOUS_GROUP
for (const child of row.config as EntryOptions[]) visitPlacedRows(child, own, visit)
}
}
@@ -35,17 +59,20 @@ export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => vo
* group's `config`, which mount as that group's children the same way an
* insert's do.
* @param patches - the patch list.
* @param visit - called once per row, with how the patch introduced it.
* @param visit - called once per row, with how the patch introduced it and where.
*/
export function visitPatchRows(patches: readonly PatchOptions[], visit: (row: EntryOptions, source: PatchRowSource) => void): void {
for (const patch of patches) {
export function visitPatchRows(
patches: readonly PatchOptions[],
visit: (row: EntryOptions, source: PatchRowSource, place: PatchRowPlace) => void,
): void {
patches.forEach((patch, index) => {
if (patch.insert !== undefined) {
for (const row of patch.insert) visitRowTree(row, (entry) => { visit(entry, 'insert') })
continue
for (const row of patch.insert) visitPlacedRows(row, patch.id, (entry, target) => { visit(entry, 'insert', { target, patch: index }) })
return
}
if (patch.id === undefined || !Array.isArray(patch.config)) continue
if (patch.id === undefined || !Array.isArray(patch.config)) return
for (const item of patch.config as unknown[]) {
if (isRowLike(item)) visitRowTree(item, (entry) => { visit(entry, 'config') })
if (isRowLike(item)) visitPlacedRows(item, patch.id, (entry, target) => { visit(entry, 'config', { target, patch: index }) })
}
}
})
}
+11 -3
View File
@@ -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
+109 -33
View File
@@ -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`)
+27 -15
View File
@@ -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')
+79 -6
View File
@@ -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 () => {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is
Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped.
When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it, or a group holding it, with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
### Per-preset compositions
+1 -1
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
### 每个预设的组合
+2 -3
View File
@@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
const entries: PluginInventoryEntry[] = []
const runtime = this.ctx.get('profileRuntime')
const failures = this.ctx.get('pluginFailures')
const userDisabled = runtime?.userDisabledRowIds() ?? new Set<string>()
const listed = new Set<string>()
for (const entry of this.ctx.loader.entries()) {
if (entry.options.group) continue
@@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
trust: origin?.trust ?? 'builtin',
...origin === undefined ? {} : { package: packageRef(origin) },
...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const },
...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
})
}
@@ -125,7 +124,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
moduleName: conflict.moduleName,
enabled: true,
fiberPhase: 'failed',
trust: conflict.packageName === undefined ? 'builtin' : 'external',
trust: conflict.packageName === undefined ? 'user' : 'external',
...conflict.packageName === undefined
? {}
: { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },
+5 -2
View File
@@ -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', {
+2 -1
View File
@@ -28,7 +28,8 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-http-proxy": "workspace:^"
"@deepseek-ai/dsh-http-proxy": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+4 -2
View File
@@ -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' } },
+2
View File
@@ -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',
])