From 5f0ad7c4b1cb2dafa966753fbf75484768ae84cb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:01:05 +0800 Subject: [PATCH 1/7] fix(boot): keep disabled bundles disabled across runs and catch config-override duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileInstalledBundles now enables only the dependencies a run added, so a bundle the user disabled keeps its state through an unrelated install or removal. Row registration records the group each row is declared under: a config override restates a row only under that group, while the same id twice in one list or under another group is a duplicate — a contained bundle is left out and a built-in layer fails loud, instead of the Loader rejecting the update or moving the row at mount. --- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/compose-stack.ts | 35 +++++--- .../boot/app-boot/src/external-bundles.ts | 81 +++++++++++++------ packages/boot/app-boot/src/patch-rows.ts | 47 ++++++++--- .../boot/app-boot/tests/compose-stack.spec.ts | 33 ++++++++ .../app-boot/tests/external-bundles.spec.ts | 51 ++++++++++++ 8 files changed, 205 insertions(+), 50 deletions(-) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 91e3505798..51919735d5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: e3ff5b06c095bdee9fdd26505ed2b63d0402a679 -README.zh.md: 62c37b1b35c113228b10bee688ecd8b248b5f26a +README.md: 7755642ba4afec550c599971375c2149f8ed6cae +README.zh.md: 3c89f4849e94b4ceedc82015e7386557f6792349 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index e3ff5b06c0..7755642ba4 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -91,7 +91,7 @@ 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. - **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. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 62c37b1b35..3c89f4849e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -91,7 +91,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **与渠道无关的库。** 此包不包含 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/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 diff --git a/packages/boot/app-boot/src/compose-stack.ts b/packages/boot/app-boot/src/compose-stack.ts index 3ca6db1200..90504a508e 100644 --- a/packages/boot/app-boot/src/compose-stack.ts +++ b/packages/boot/app-boot/src/compose-stack.ts @@ -93,29 +93,44 @@ function rowIds(row: EntryOptions): string[] { * rows it inserts and the rows its config overrides set as a group's * children. Built-in and boot-staged layers claim first, in manifest order; * an id two of them declare, or one inserts twice, is a defect of the shipped - * composition and throws, while a config override restating a row the same - * layer declared is that layer keeping its own child. Contained external - * layers then claim in manifest order, each rendered once here; one whose id - * is already owned, or which declares an id twice, is left out whole. + * composition and throws, while a config override restating a row under the + * group the same layer declared it in is that layer keeping its own child; + * the same id twice in one config list, or set under another group, is + * declared twice. Contained external layers then claim in manifest order, + * each rendered once here; one whose id is already owned, or which declares + * an id twice, is left out whole. * @param layers - the profile's bundle layers, in manifest order. * @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one. * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice. */ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership { const owners = new Map() + const declaredUnder = new Map() for (const layer of layers) { if (isContainedLayer(layer)) continue - visitPatchRows(layer.patches, (row, source) => { + let listed = new Set() + let listIndex = -1 + visitPatchRows(layer.patches, (row, source, place) => { if (typeof row.id !== 'string') return + if (place.patch !== listIndex) { + listIndex = place.patch + listed = new Set() + } const owner = owners.get(row.id) if (owner === layer) { - if (source === 'insert') throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`) - return - } - if (owner !== undefined) { + // Restating a row under the group that already holds it is the layer + // keeping its child; inserting it again, listing it twice in one + // config, or setting it under another group is declaring it twice. + if (source === 'insert' || listed.has(row.id) || declaredUnder.get(row.id) !== place.target) { + throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`) + } + } else if (owner !== undefined) { throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`) + } else { + owners.set(row.id, layer) + declaredUnder.set(row.id, place.target) } - owners.set(row.id, layer) + listed.add(row.id) }) } const skipped = new Map() diff --git a/packages/boot/app-boot/src/external-bundles.ts b/packages/boot/app-boot/src/external-bundles.ts index 61197295ac..6cb2001f61 100644 --- a/packages/boot/app-boot/src/external-bundles.ts +++ b/packages/boot/app-boot/src/external-bundles.ts @@ -59,7 +59,11 @@ export interface ComposedExternalLayer { * names; a repeated id keeps its first module. */ rows: Map - /** Ids the layer introduces more than once, in order of repetition; a config override restating a row is not one. */ + /** + * Ids the layer introduces more than once, in order of repetition. A config + * override restating a row under the group that already holds it is not + * one; the same id twice in one config list, or set under another group, is. + */ duplicates: DuplicateRow[] /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */ overrides: string[] @@ -87,7 +91,9 @@ export function isContainedLayer(layer: ProfileLayer): boolean { * an override when it addresses a row the bundle did not introduce; the rows * it sets as a group's config count as the bundle's own, since they mount * as children like inserted ones. Every id, declared or generated, goes - * through one registration, so a row spelling a wrapper's id is a duplicate. + * through one registration, so a row spelling a wrapper's id is a duplicate, + * and so is a config row the bundle already declared under another group, + * or listed twice: the Loader would move the first and reject the second. * Ids are indexed before any patch is emitted, so an insert into a group the * bundle introduces later in its list still counts as its own. * @param layer - the resolved external layer. @@ -96,20 +102,40 @@ export function isContainedLayer(layer: ProfileLayer): boolean { export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer { const groupId = bundleGroupId(layer.packageName) const rows = new Map() + const declaredUnder = new Map() const duplicates: DuplicateRow[] = [] - const claim = (rowId: string, moduleName: string): void => { - if (rows.has(rowId)) duplicates.push({ rowId, moduleName }) - else rows.set(rowId, moduleName) - } - visitPatchRows(layer.patches, (row, source) => { - if (typeof row.id !== 'string') return - // A config override restates the children it keeps: the id counts as - // the bundle's own without being a repeat. - if (source === 'config') { - if (!rows.has(row.id)) rows.set(row.id, row.name) + const claim = (rowId: string, moduleName: string, target?: string): void => { + if (rows.has(rowId)) { + duplicates.push({ rowId, moduleName }) return } - claim(row.id, row.name) + rows.set(rowId, moduleName) + declaredUnder.set(rowId, target) + } + let listed = new Set() + let listIndex = -1 + visitPatchRows(layer.patches, (row, source, place) => { + if (typeof row.id !== 'string') return + if (place.patch !== listIndex) { + listIndex = place.patch + listed = new Set() + } + // A root insert lands in the bundle's group: that is the group it declares under. + const target = place.target ?? groupId + if (source === 'config') { + // A config override restates the children it keeps: the same id declared + // under the same group before is that row. Twice in one list, or under + // another group, it would mount as a rejected duplicate or move the row. + if (listed.has(row.id) || (rows.has(row.id) && declaredUnder.get(row.id) !== target)) { + duplicates.push({ rowId: row.id, moduleName: row.name }) + } else if (!rows.has(row.id)) { + rows.set(row.id, row.name) + declaredUnder.set(row.id, target) + } + } else { + claim(row.id, row.name, target) + } + listed.add(row.id) }) claim(groupId, CONTAINED_GROUP_MODULE) const wrappers = new Map() @@ -176,7 +202,7 @@ export interface BundleReconciliation { removed: string[] /** Newly added dependencies that declare no `dsh.bundle` (plain libraries or plugin modules). */ plain: string[] - /** Installed bundles left out of the layer list because `autoEnable` was off. */ + /** Bundles the run installed and left out of the layer list because `autoEnable` was off. */ installedOnly: string[] } @@ -184,9 +210,11 @@ export interface BundleReconciliation { * Reconcile `dsh.profile.bundles` against the installed state after a pnpm * run. A dependency that no longer resolves to a bundle leaves the layer * list; template bundles (never dependencies) are untouched. A dependency - * that resolves to a bundle joins the list only when `autoEnable` is set — - * the CLI's install-and-enable semantics — and is otherwise reported as - * installed-only, which is the plugin manager's install step. + * the run added that resolves to a bundle joins the list only when + * `autoEnable` is set — the CLI's install-and-enable semantics — and is + * otherwise reported as installed-only, which is the plugin manager's install + * step. A bundle installed before the run keeps its place in or out of the + * list: one the user disabled stays disabled through an unrelated run. * @param binName - the diagnostic prefix used by manifest reads. * @param profileDir - the profile directory. * @param installAnchor - absolute path of the dsh app's package.json. @@ -207,16 +235,17 @@ export function reconcileInstalledBundles( const bundles = [...after.dsh?.profile?.bundles ?? []] const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] } for (const packageName of dependencies) { - const isBundle = exportsBundlePatch(binName, packageName, installAnchor, profileDir) - if (isBundle && !bundles.includes(packageName)) { - if (options.autoEnable) { - bundles.push(packageName) - outcome.enabled.push(packageName) - } else { - outcome.installedOnly.push(packageName) - } - } else if (!isBundle && !beforeDeps.has(packageName)) { + if (beforeDeps.has(packageName)) continue + if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) { outcome.plain.push(packageName) + continue + } + if (bundles.includes(packageName)) continue + if (options.autoEnable) { + bundles.push(packageName) + outcome.enabled.push(packageName) + } else { + outcome.installedOnly.push(packageName) } } const dependencySet = new Set(dependencies) diff --git a/packages/boot/app-boot/src/patch-rows.ts b/packages/boot/app-boot/src/patch-rows.ts index 0f227d20d6..ddc42a1545 100644 --- a/packages/boot/app-boot/src/patch-rows.ts +++ b/packages/boot/app-boot/src/patch-rows.ts @@ -11,6 +11,22 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' /** How a patch introduced a row: inserted it, or set it as a group's `config`. */ export type PatchRowSource = 'insert' | 'config' +/** + * Where a patch introduced a row. `target` is the id of the group the row is + * declared under — the patch's target, or the enclosing group row for a nested + * one — undefined for a root-level insert, and a private marker for a child of + * a group without an id, which no later patch can address. `patch` is the + * index of the patch in the list, so the same id twice in one config list can + * be told from a restatement across patches. + */ +export interface PatchRowPlace { + readonly target: string | undefined + readonly patch: number +} + +/** The target of rows nested in a group that declares no id: nothing can restate them, so nothing may match it. */ +const ANONYMOUS_GROUP = '\u0000anonymous-group' + /** Whether a config item is a row: a plain object naming a module. */ function isRowLike(value: unknown): value is EntryOptions { return typeof value === 'object' && value !== null && !Array.isArray(value) && typeof (value as { name?: unknown }).name === 'string' @@ -23,9 +39,17 @@ function isRowLike(value: unknown): value is EntryOptions { * @param visit - called once per row. */ export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => void): void { - visit(row) + visitPlacedRows(row, undefined, (entry) => { visit(entry) }) +} + +/** The walk behind {@link visitRowTree}, carrying the group each row is declared under. */ +function visitPlacedRows( + row: EntryOptions, target: string | undefined, visit: (row: EntryOptions, target: string | undefined) => void, +): void { + visit(row, target) if (row.group && Array.isArray(row.config)) { - for (const child of row.config as EntryOptions[]) visitRowTree(child, visit) + const own = typeof row.id === 'string' ? row.id : ANONYMOUS_GROUP + for (const child of row.config as EntryOptions[]) visitPlacedRows(child, own, visit) } } @@ -35,17 +59,20 @@ export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => vo * group's `config`, which mount as that group's children the same way an * insert's do. * @param patches - the patch list. - * @param visit - called once per row, with how the patch introduced it. + * @param visit - called once per row, with how the patch introduced it and where. */ -export function visitPatchRows(patches: readonly PatchOptions[], visit: (row: EntryOptions, source: PatchRowSource) => void): void { - for (const patch of patches) { +export function visitPatchRows( + patches: readonly PatchOptions[], + visit: (row: EntryOptions, source: PatchRowSource, place: PatchRowPlace) => void, +): void { + patches.forEach((patch, index) => { if (patch.insert !== undefined) { - for (const row of patch.insert) visitRowTree(row, (entry) => { visit(entry, 'insert') }) - continue + for (const row of patch.insert) visitPlacedRows(row, patch.id, (entry, target) => { visit(entry, 'insert', { target, patch: index }) }) + return } - if (patch.id === undefined || !Array.isArray(patch.config)) continue + if (patch.id === undefined || !Array.isArray(patch.config)) return for (const item of patch.config as unknown[]) { - if (isRowLike(item)) visitRowTree(item, (entry) => { visit(entry, 'config') }) + if (isRowLike(item)) visitPlacedRows(item, patch.id, (entry, target) => { visit(entry, 'config', { target, patch: index }) }) } - } + }) } diff --git a/packages/boot/app-boot/tests/compose-stack.spec.ts b/packages/boot/app-boot/tests/compose-stack.spec.ts index b0971025c5..55313c82c9 100644 --- a/packages/boot/app-boot/tests/compose-stack.spec.ts +++ b/packages/boot/app-boot/tests/compose-stack.spec.ts @@ -73,6 +73,39 @@ 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: [ + { 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([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', () => { diff --git a/packages/boot/app-boot/tests/external-bundles.spec.ts b/packages/boot/app-boot/tests/external-bundles.spec.ts index aab736f251..43b39f5fa3 100644 --- a/packages/boot/app-boot/tests/external-bundles.spec.ts +++ b/packages/boot/app-boot/tests/external-bundles.spec.ts @@ -163,6 +163,35 @@ describe('composeExternalLayer', () => { expect(composed.rows.get('dup')).toBe('pkg-r/one') }) + it('reports a config override that lists one id twice or sets a row under a group other than the one holding it', () => { + const composed = composeExternalLayer(layer('pkg-o', [ + { insert: [ + { id: 'a', name: 'cordis:group', group: true, config: [{ id: 'shared', name: 'pkg-o/x' }] }, + { id: 'b', name: 'cordis:group', group: true, config: [] }, + ] }, + { id: 'b', config: [{ id: 'shared', name: 'pkg-o/moved' }] }, + { id: 'a', config: [{ id: 'twice', name: 'pkg-o/one' }, { id: 'twice', name: 'pkg-o/two' }] }, + // The bundle's own group is a predictable target: a duplicate under it + // would fail the group's update before any row is contained. + { id: 'bundle/pkg-o', config: [{ id: 'own', name: 'pkg-o/own' }, { id: 'own', name: 'pkg-o/own-again' }] }, + ])) + expect(composed.duplicates).toEqual([ + { rowId: 'shared', moduleName: 'pkg-o/moved' }, + { rowId: 'twice', moduleName: 'pkg-o/two' }, + { rowId: 'own', moduleName: 'pkg-o/own-again' }, + ]) + expect(composed.overrides).toEqual([]) + }) + + it('lets a config override restate a row under the built-in group it was inserted into', () => { + const composed = composeExternalLayer(layer('pkg-s', [ + { id: 'tools', insert: [{ id: 'tool', name: 'pkg-s/tool' }] }, + { id: 'tools', config: [{ id: 'tool', name: 'pkg-s/tool' }] }, + ])) + expect(composed.duplicates).toEqual([]) + expect(composed.overrides).toEqual(['tools']) + }) + it('never mutates the layer\'s own patch objects', () => { const patches: PatchOptions[] = [{ insert: [{ id: 'row', name: 'pkg-d' }] }, { id: 'row', config: { a: 1 } }] const snapshot = structuredClone(patches) @@ -242,6 +271,28 @@ describe('reconcileInstalledBundles', () => { expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false) }) + it('leaves a bundle installed before the run where the user left it, in or out of the list', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'new-bundle': { bundle: true } }) + // ext-bundle was installed earlier and is not listed: the user disabled it. + const before = { dependencies: { 'ext-bundle': '1.0.0' } } + expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true })) + .toEqual({ enabled: ['new-bundle'], removed: [], plain: [], installedOnly: [] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'new-bundle']) + disableBundle(NAME, profileDir, 'new-bundle') + expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, readProfileManifest(NAME, profileDir), { autoEnable: true })) + .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + }) + + it('keeps a listed bundle listed once when the run brings its dependency back', () => { + const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } }) + enableBundle(NAME, profileDir, installAnchor, 'ext-bundle') + // The manifest the run started with had lost the dependency; pnpm added it again. + expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, { dependencies: {} }, { autoEnable: true })) + .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] }) + expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle']) + }) + it('drops a layer whose dependency was removed and keeps template bundles', () => { const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } }) enableBundle(NAME, profileDir, installAnchor, 'ext-bundle') From 6bc5e978ae05774dabb844a6d49b742981f5198e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:05:39 +0800 Subject: [PATCH 2/7] fix(boot): harden the package probe's child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe child now runs with the harness's credential-shaped variables scrubbed — the one rule every spawner uses, now defined in dsh-launch-environment and re-exported by dsh-subprocess — and its report is the one message echoing a per-run token the child removes from its environment, together with process.send, before importing anything. The failure text keeps the last 16 KiB of stderr instead of every byte, and the probe settles only on the child's close, so no pipe or channel outlives the call. --- ...oped-fail-loud-and-package-probe.i18n.yaml | 4 +- ...boot-scoped-fail-loud-and-package-probe.md | 2 +- ...t-scoped-fail-loud-and-package-probe.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/probe-child.ts | 14 +++- packages/boot/app-boot/src/probe-report.ts | 3 + packages/boot/app-boot/src/probe.ts | 80 +++++++++++++------ packages/boot/app-boot/tests/probe.spec.ts | 41 +++++++++- packages/subprocess/subprocess/package.json | 3 +- packages/subprocess/subprocess/src/index.ts | 6 +- .../util/launch-environment/README.i18n.yaml | 4 +- packages/util/launch-environment/README.md | 4 + packages/util/launch-environment/README.zh.md | 4 + packages/util/launch-environment/src/index.ts | 21 +++++ .../tests/launch-environment.spec.ts | 11 ++- 17 files changed, 161 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml index c8ab0a5710..596525dd44 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md -2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c0ae61e1323de3dcbeec826363dc953c32dbdcc5 -2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: d4e136e36dcf90ad78b499123bfe56add868484d +2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 940b500f9f0b5c531e0c0eaf849a8a8000a90a6a +2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 2205a1abfba92c06b3e65745a9d79bb09421bdb2 diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md index c0ae61e132..940b500f9f 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md @@ -16,7 +16,7 @@ Separately, nothing could say what an installed package was without importing it **Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean. -**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: an unrecognized report is a rejection, an unrecognized record is probed again. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version. +**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: a message that does not echo the run's token is the package's own, not the report, and an unrecognized record is probed again. The child gets the parent environment minus credential-shaped names, removes the token and `process.send` before importing, keeps at most 16 KiB of stderr for the failure text, and is awaited to `close` after the kill so nothing of it outlives the call. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md index d4e136e36d..2205a1abfb 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md @@ -16,7 +16,7 @@ Status: implemented **嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。 -**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:无法识别的报告是一次 rejection,无法识别的记录重新探测。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。 +**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:没有回显本次 token 的消息是包自己的、不算报告,无法识别的记录重新探测。子进程拿到的是剔除了密钥形态变量的父环境,import 之前先删掉 token 与 `process.send`,stderr 只留最后 16 KiB 作失败文本,kill 之后等到 `close` 才结算,所以子进程的任何东西都不会活过这次调用。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。 ## 考虑过的替代方案 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 51919735d5..ba9afc8f9d 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 7755642ba4afec550c599971375c2149f8ed6cae -README.zh.md: 3c89f4849e94b4ceedc82015e7386557f6792349 +README.md: 2da207fb7a04db7fc73eb1b04a921d3e2ddd10dc +README.zh.md: 217881ccf67bb3213eaff4cd442ff386d5897304 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 7755642ba4..2da207fb7a 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -93,7 +93,7 @@ This section explains how the outcomes above are realized and points at the code - **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that 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`. 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 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. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 3c89f4849e..217881ccf6 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -93,7 +93,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。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 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`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`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 diff --git a/packages/boot/app-boot/src/probe-child.ts b/packages/boot/app-boot/src/probe-child.ts index 2a0016b817..ca94965672 100644 --- a/packages/boot/app-boot/src/probe-child.ts +++ b/packages/boot/app-boot/src/probe-child.ts @@ -4,7 +4,8 @@ * over the IPC channel the parent opened. stdout and stderr stay the * imported modules' own, so a package that prints at import still reports. * Arguments: the package directory, the main specifier (empty for none), and - * the addable specifiers as a JSON array. Nothing here runs inside the host. + * the addable specifiers as a JSON array; the environment carries the token + * the report echoes. Nothing here runs inside the host. * @module @deepseek-ai/dsh-app-boot/probe-child */ @@ -13,6 +14,13 @@ import type { ChildInspection, ChildReport } from './probe-report.ts' const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2) const base = pathToFileURL(`${dir}/package.json`).href +// The report is the parent's to receive: its token leaves the environment and +// the channel's `send` leaves `process` before any of the package's code runs, +// so nothing that code sends at import can pass as the report. +const token = process.env.DSH_PROBE_REPORT ?? '' +delete process.env.DSH_PROBE_REPORT +const send = process.send?.bind(process) +Reflect.deleteProperty(process, 'send') /** Import one module from the package and describe what it exports. */ async function inspect(specifier: string): Promise { @@ -31,7 +39,7 @@ async function inspect(specifier: string): Promise { } } -const report: ChildReport = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} } +const report: ChildReport = { token, cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} } try { report.cordis = import.meta.resolve('@deepseek-ai/cordis', base) } catch { @@ -39,4 +47,4 @@ try { } if (mainSpecifier !== '') report.main = await inspect(mainSpecifier) for (const name of JSON.parse(addableJson) as string[]) report.addable[name] = await inspect(name) -process.send?.(report, undefined, undefined, () => { process.disconnect() }) +send?.(report, undefined, undefined, () => { process.disconnect() }) diff --git a/packages/boot/app-boot/src/probe-report.ts b/packages/boot/app-boot/src/probe-report.ts index e026df7f70..7e4fdc1869 100644 --- a/packages/boot/app-boot/src/probe-report.ts +++ b/packages/boot/app-boot/src/probe-report.ts @@ -20,6 +20,8 @@ export interface ChildInspection { /** The child's one message: where cordis resolves from the package, and what each module imported as. */ export interface ChildReport { + /** The token the parent handed the child for this run; a message without it is not the report. */ + token: string /** The URL the package resolves `@deepseek-ai/cordis` to, or null when it does not resolve it. */ cordis: string | null /** The main export's inspection; the not-imported default when the package declares no main. */ @@ -53,6 +55,7 @@ function isInspection(value: unknown): value is ChildInspection { */ export function parseChildReport(value: unknown): ChildReport | undefined { if (!isRecord(value)) return undefined + if (typeof value.token !== 'string') return undefined if (value.cordis !== null && typeof value.cordis !== 'string') return undefined if (!isInspection(value.main) || !isRecord(value.addable)) return undefined if (!Object.values(value.addable).every(isInspection)) return undefined diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index 4456c9cd2a..7ff35e9b47 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -10,9 +10,11 @@ */ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { withoutSensitiveEnv } from '@deepseek-ai/dsh-launch-environment' import { loadOverlayPatches } from './index.ts' import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts' import { visitPatchRows } from './patch-rows.ts' @@ -136,54 +138,82 @@ function childEntryArgs(): string[] { * the probe needs, so the child is killed once it arrived: a package that * keeps a timer alive after import costs nothing more. stdout is not read * at all, so whatever the imported modules print cannot corrupt the report. + * The child gets the parent environment minus credential-shaped names, plus + * a per-run token it echoes in its report — a message without the token is + * the package's own, not the report — and the probe settles only once the + * child closed, so its pipes and channel are gone when the caller continues. */ function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS return new Promise((resolve, reject) => { + const token = randomUUID() const child = spawn( options.nodeExecutable ?? process.execPath, ['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)], - { cwd: options.profileDir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + { + cwd: options.profileDir, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token }, + }, ) + // The tail of stderr, for the failure text: a package that floods stderr at + // import must not grow this process's heap by as much. const err: Buffer[] = [] - child.stderr?.on('data', (chunk: Buffer) => err.push(chunk)) - // One settlement: a spawn failure emits `error` and then `close`, a - // timeout kill emits `close` after the rejection below, and the kill - // after a report emits `close` after the resolution. - let settled = false - let unrecognized = false - const settle = (outcome: () => void): void => { - if (settled) return - settled = true + let errBytes = 0 + child.stderr?.on('data', (chunk: Buffer) => { + err.push(chunk) + errBytes += chunk.length + while (errBytes > STDERR_TAIL_BYTES && err.length > 1) errBytes -= (err.shift() as Buffer).length + if (errBytes > STDERR_TAIL_BYTES) { + err[0] = (err[0] as Buffer).subarray(errBytes - STDERR_TAIL_BYTES) + errBytes = STDERR_TAIL_BYTES + } + }) + // The outcome lands on `close`. A kill — after the report, or at the + // timeout — records its outcome and waits for the close it causes, so the + // child's pipes and channel are gone when the caller continues; a spawn + // failure emits `error` with no process to wait for. + let outcome: (() => void) | undefined + const finish = (next: () => void): void => { + /* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */ + if (outcome !== undefined) return + outcome = next clearTimeout(timer) - outcome() + child.kill('SIGKILL') } const timer = setTimeout(() => { - child.kill('SIGKILL') - settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) }) + finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) }) }, timeoutMs) child.on('message', (message) => { const report = parseChildReport(message) - if (report === undefined) { - unrecognized = true + /* v8 ignore next -- the imported code finds no process.send; a message through the raw channel would still lack the token */ + if (report === undefined || report.token !== token) return + finish(() => { resolve(report) }) + }) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timer) + if (outcome !== undefined) { + outcome() return } - child.kill('SIGKILL') - settle(() => { resolve(report) }) - }) - child.on('error', (error) => { settle(() => { reject(error) }) }) - child.on('close', (code) => { - settle(() => { - reject(new Error(unrecognized - ? `${options.binName}: probe of ${options.packageName} reported an unrecognized value` - : `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`)) - }) + const tail = Buffer.concat(err).toString('utf8').trim() + reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`)) }) }) } const DEFAULT_TIMEOUT_MS = 20_000 +/** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */ +const STDERR_TAIL_BYTES = 16 * 1024 + +/** The environment name carrying the run's report token; the child removes it before importing anything. */ +const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT' + /** The rows a bundle patch introduces, flattened from nested groups, with the ids of overrides on other rows. */ function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } { const rows: PluginProbeRow[] = [] diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index a46c395dc9..e86cf96e9e 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -175,6 +175,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 +214,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 +281,11 @@ describe('probe cache', () => { describe('parseChildReport', () => { it('accepts the child\'s message only with every field in place', () => { const inspection = { ok: true, isPlugin: true, configSchema: null } - const report: ChildReport = { cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } } + const report: ChildReport = { token: 't', cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } } expect(parseChildReport(report)).toBe(report) expect(parseChildReport({ ...report, cordis: 'file:///cordis/index.js' })).toBeDefined() const broken: Record[] = [ - { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } }, + { token: undefined }, { token: 1 }, { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } }, { main: { ok: true, isPlugin: true } }, { main: { ...inspection, error: 1 } }, { addable: [] }, { addable: { 'pkg/x': 1 } }, ] for (const fields of broken) expect(parseChildReport({ ...report, ...fields }), JSON.stringify(fields)).toBeUndefined() diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index bb5f574ccb..1a02b44cfe 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -28,7 +28,8 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-http-proxy": "workspace:^" + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index d31b3685e4..d3e527241a 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy' +import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-launch-environment' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' @@ -38,11 +39,12 @@ export type { /** * Credential-shaped environment names are NOT forwarded to children (the * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned - * process implicitly). One heuristic for every in-repo spawner; a + * process implicitly). One heuristic for every in-repo spawner, defined in + * `dsh-launch-environment` so the package probe scrubs by the same rule; a * deliberately supplied entry survives because explicit env layers merge * after the scrub. */ -export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i +export { SENSITIVE_ENV_PATTERN } /** * The ambient parent environment minus credential-shaped names and minus all diff --git a/packages/util/launch-environment/README.i18n.yaml b/packages/util/launch-environment/README.i18n.yaml index 83cdcd7d55..0d56528bae 100644 --- a/packages/util/launch-environment/README.i18n.yaml +++ b/packages/util/launch-environment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/launch-environment/README.md -README.md: 312952f527af02c9bd9b19d3560d65c84b446b7d -README.zh.md: 3071052c941737b365d1557ead005ede5ba857d3 +README.md: 0bb0bba6f5b29cc88f5919ee70fba89b28eb0cb8 +README.zh.md: d5707d7a35022797683c0300031de3acdde2f3c2 diff --git a/packages/util/launch-environment/README.md b/packages/util/launch-environment/README.md index 312952f527..0bb0bba6f5 100644 --- a/packages/util/launch-environment/README.md +++ b/packages/util/launch-environment/README.md @@ -51,6 +51,10 @@ Names match the way the platform matches them: exactly on POSIX, case-insensitiv `launchEnvironmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. The fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has is the environment it was launched with. +### Scrubbing credentials for a child + +`withoutSensitiveEnv(process.env)` returns the environment without its credential-shaped entries — names matching `SENSITIVE_ENV_PATTERN` (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`, case-insensitively) — and without unset values, for a child that must not see the harness's credentials: the package probe in `dsh-app-boot` spawns with it, and `dsh-subprocess` scrubs every spawned command by the same pattern. + ----- diff --git a/packages/util/launch-environment/README.zh.md b/packages/util/launch-environment/README.zh.md index 3071052c94..d5707d7a35 100644 --- a/packages/util/launch-environment/README.zh.md +++ b/packages/util/launch-environment/README.zh.md @@ -51,6 +51,10 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value 当产品 CLI 引导了这棵树时,`launchEnvironmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切就是它被启动时的环境。 +### 为子进程剔除凭据 + +`withoutSensitiveEnv(process.env)` 返回去掉了密钥形态条目——名字匹配 `SENSITIVE_ENV_PATTERN`(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`,不分大小写)——且去掉了未设置值的环境,给不该看到 harness 凭据的子进程用:`dsh-app-boot` 的包探针用它生成子进程,`dsh-subprocess` 按同一模式清洗每条被生成的命令。 + ----- diff --git a/packages/util/launch-environment/src/index.ts b/packages/util/launch-environment/src/index.ts index b12d936666..9b83eff96d 100644 --- a/packages/util/launch-environment/src/index.ts +++ b/packages/util/launch-environment/src/index.ts @@ -122,3 +122,24 @@ declare module '@deepseek-ai/cordis' { launchEnvironment?: LaunchEnvironmentSnapshot } } + +/** + * Environment names that carry credentials — API keys, passwords, secrets, + * tokens — matched case-insensitively. The one rule every harness child spawn + * scrubs by, so the harness's own credentials never reach a child implicitly. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i + +/** + * An environment without its credential-shaped entries, for a child that must + * not see the harness's credentials: a package probe, a spawned tool. + * @param env - the environment to scrub, typically `process.env`. + * @returns a fresh object holding every entry whose name is not credential-shaped and whose value is set. + */ +export function withoutSensitiveEnv(env: NodeJS.ProcessEnv): Record { + const scrubbed: Record = {} + for (const [name, value] of Object.entries(env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(name)) scrubbed[name] = value + } + return scrubbed +} diff --git a/packages/util/launch-environment/tests/launch-environment.spec.ts b/packages/util/launch-environment/tests/launch-environment.spec.ts index 973b86357d..72eb02a50a 100644 --- a/packages/util/launch-environment/tests/launch-environment.spec.ts +++ b/packages/util/launch-environment/tests/launch-environment.spec.ts @@ -1,9 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { - createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf, + createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf, SENSITIVE_ENV_PATTERN, withoutSensitiveEnv, } from '../src/index.ts' +describe('withoutSensitiveEnv', () => { + it('drops credential-shaped names, case-insensitively, and unset values', () => { + expect(withoutSensitiveEnv({ + PATH: '/bin', DEEPSEEK_API_KEY: 'k', npm_config_token: 't', Secret_Thing: 's', DB_PASSWORD: 'p', UNSET: undefined, + })).toEqual({ PATH: '/bin' }) + expect(SENSITIVE_ENV_PATTERN.test('HOME')).toBe(false) + }) +}) + const layered = createLaunchEnvironmentSnapshot([ { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } }, { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } }, From 20102bcd527bc1136ba1a3829e21febfb5e5028f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:13:03 +0800 Subject: [PATCH 3/7] fix(boot): commit user-disabled rows with the composition and name a user row's trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's user-disabled row ids now come from the committed composition — composeProfileStack collects them from the user layers it composed — so a rejected or half-written patch file no longer makes the list disagree with the running tree or fail to parse. The inventory attributes a row disabled through a group the user disabled to the user, through the shared userDisablesEntry walk, and lists a user-layer row the composition left out under the new user trust instead of builtin. --- apps/cli/src/profile-boot.ts | 4 -- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 12 +++--- docs/subsystems/core.zh.md | 12 +++--- packages/boot/app-boot/src/compose-stack.ts | 11 +++++- packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/profile-runtime.ts | 38 +++++++++++-------- .../boot/app-boot/tests/compose-stack.spec.ts | 11 ++++++ .../app-boot/tests/profile-runtime.spec.ts | 35 ++++++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 6 +-- .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/src/index.ts | 6 +-- packages/host/plugin-inventory/src/types.ts | 7 +++- .../plugin-inventory/tests/inventory.spec.ts | 18 ++++++++- scripts/gen-cordis-catalog.ts | 1 + 17 files changed, 112 insertions(+), 62 deletions(-) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index c9b33a40eb..46696de064 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -312,10 +312,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con loadProfile: () => prepareProfile(options.profile), compose: composeFor, rootEntry: () => rootIncludeEntry(ctx), - readUserPatches: () => [ - ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], - ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ], }) const runtime = ctx.get('profileRuntime') if (runtime !== undefined) app.runtime = runtime diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 511534d2e4..83f0caf22c 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 44975507f4a31aae49b644593a4dcc74864df629 -core.zh.md: b91f236cf4d65928e5806d8d30d75c23d7b5f703 +core.md: 4028b19620e72a3c39164444453bb5fc1f818d7f +core.zh.md: 2c75c867db13173c6db34b8a83f47c3f10f28972 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 44975507f4..4028b19620 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -853,13 +853,13 @@ Facts and recomposition of the booted profile. originOf(rowId: string): RowOrigin | undefined /** - * Row ids the user patch layers disable with a literal `disabled: true`. - * A `!!js` gate in a user file stays an expression node when read from - * disk, so it is a condition, not a user decision, and is left to the - * composition. - * @returns the ids, re-read from disk on every call. + * Row ids the user patch layers disable with a literal `disabled: true`, + * as the committed composition read them. The set describes the running + * tree: a user file the include rejected, or one that cannot be parsed, + * changes nothing here until a composition with it is accepted. + * @returns the ids, from the committed composition. */ -userDisabledRowIds(): Set +userDisabledRowIds(): ReadonlySet /** * Recompose the host tree from the profile's layers and the user patch files diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index b91f236cf4..2c75c867db 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -863,13 +863,13 @@ Facts and recomposition of the booted profile. originOf(rowId: string): RowOrigin | undefined /** - * Row ids the user patch layers disable with a literal `disabled: true`. - * A `!!js` gate in a user file stays an expression node when read from - * disk, so it is a condition, not a user decision, and is left to the - * composition. - * @returns the ids, re-read from disk on every call. + * Row ids the user patch layers disable with a literal `disabled: true`, + * as the committed composition read them. The set describes the running + * tree: a user file the include rejected, or one that cannot be parsed, + * changes nothing here until a composition with it is accepted. + * @returns the ids, from the committed composition. */ -userDisabledRowIds(): Set +userDisabledRowIds(): ReadonlySet /** * Recompose the host tree from the profile's layers and the user patch files diff --git a/packages/boot/app-boot/src/compose-stack.ts b/packages/boot/app-boot/src/compose-stack.ts index 90504a508e..9f072bdb1d 100644 --- a/packages/boot/app-boot/src/compose-stack.ts +++ b/packages/boot/app-boot/src/compose-stack.ts @@ -58,6 +58,12 @@ export interface ComposedStack { readonly conflicts: RowConflict[] /** External bundles left out because a row id was already claimed or repeated. */ readonly skippedBundles: string[] + /** + * Row ids the user layers disable with a literal `disabled: true`, as + * composed; a `!!js` gate stays an expression node when read from disk, so + * it is a condition of the composition, not a user decision. + */ + readonly userDisabledRowIds: ReadonlySet } /** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */ @@ -166,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( @@ -193,10 +199,12 @@ export function composeProfileStack( } const claimed = new Map() for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName) + const userDisabledRowIds = new Set() for (const userLayer of userLayers) { const patches: PatchOptions[] = [] for (const patch of userLayer.patches) { if (patch.insert === undefined) { + if (patch.id !== undefined && patch.disabled === true) userDisabledRowIds.add(patch.id) patches.push(patch) continue } @@ -222,6 +230,7 @@ export function composeProfileStack( owners: ownership.owners, conflicts, skippedBundles, + userDisabledRowIds, } } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 0e824e1473..4706d81398 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -65,6 +65,7 @@ export { } from './compose-stack.ts' export { ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin, + userDisablesEntry, } from './profile-runtime.ts' export { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index 446d530ab6..2a137563dd 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -18,7 +18,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import type Include from '@deepseek-ai/cordis-plugin-include' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest' import type { ComposedStack, RowConflict } from './compose-stack.ts' import type { BundleTrust, Profile, ProfileLayer } from './profile.ts' @@ -52,8 +51,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. */ @@ -124,19 +121,14 @@ export class ProfileRuntime extends Service { } /** - * Row ids the user patch layers disable with a literal `disabled: true`. - * A `!!js` gate in a user file stays an expression node when read from - * disk, so it is a condition, not a user decision, and is left to the - * composition. - * @returns the ids, re-read from disk on every call. + * Row ids the user patch layers disable with a literal `disabled: true`, + * as the committed composition read them. The set describes the running + * tree: a user file the include rejected, or one that cannot be parsed, + * changes nothing here until a composition with it is accepted. + * @returns the ids, from the committed composition. */ - userDisabledRowIds(): Set { - const ids = new Set() - for (const patch of this.options.readUserPatches()) { - if (patch.insert !== undefined || patch.id === undefined) continue - if (patch.disabled === true) ids.add(patch.id) - } - return ids + userDisabledRowIds(): ReadonlySet { + return this.committed.stack.userDisabledRowIds } /** @@ -176,3 +168,19 @@ export class ProfileRuntime extends Service { this.committed = { profile, stack } } } + +/** + * Whether the user patch layers disable an entry: its own row id, or the id + * of a group holding it, is in the set. 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. + * @param userDisabled - the ids the user layers disable, from `userDisabledRowIds()`. + * @returns true when the user's patches disable the entry or one of the groups holding it. + */ +export function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet): boolean { + for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) { + if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true + } + return false +} diff --git a/packages/boot/app-boot/tests/compose-stack.spec.ts b/packages/boot/app-boot/tests/compose-stack.spec.ts index 55313c82c9..6088c14e10 100644 --- a/packages/boot/app-boot/tests/compose-stack.spec.ts +++ b/packages/boot/app-boot/tests/compose-stack.spec.ts @@ -133,6 +133,17 @@ describe('claimLayerIds', () => { }) describe('composeProfileStack', () => { + it('collects the rows the user layers disable with a literal disabled: true', () => { + const stack = composeProfileStack(NAME, [base], [{ label: '/p/cordis.patch.yml', patches: [ + { id: 'a', disabled: true }, + // A gate read from disk is an expression node: a condition of the composition, not a decision. + { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean }, + { id: 'c', config: {} }, + { insert: [{ id: 'd', name: 'x', disabled: true }] }, + ] }]) + expect([...stack.userDisabledRowIds]).toEqual(['a']) + }) + it('mounts owning layers in manifest order and drops a user insert of a taken id', () => { const ext = layer('ext', 'external', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }]) const stack = composeProfileStack(NAME, [base, ext], [ diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index 63591d3ca3..b9dd5de0b9 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/packages/boot/app-boot/tests/profile-runtime.spec.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts' +import { claimLayerIds, ProfileRuntime, userDisablesEntry, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts' const contexts: Context[] = [] afterEach(async () => { @@ -24,7 +24,7 @@ function profile(layers: ProfileLayer[]): Profile { async function harness( layers: ProfileLayer[], - options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {}, + options: { rootEntry?: () => Entry | undefined; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {}, ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType }> { const ctx = new Context() contexts.push(ctx) @@ -37,6 +37,7 @@ async function harness( owners: claimLayerIds(current.layers).owners, conflicts: current === options.reloaded ? options.conflicts ?? [] : [], skippedBundles: [], + userDisabledRowIds: new Set(current === options.reloaded ? ['reloaded-off'] : ['booted-off']), } }) const booted = profile(layers) @@ -46,7 +47,6 @@ async function harness( loadProfile: () => options.reloaded ?? profile(layers), compose, rootEntry: options.rootEntry ?? (() => undefined), - readUserPatches: () => options.userPatches ?? [], }) return { ctx, runtime: ctx.profileRuntime, compose } } @@ -91,16 +91,22 @@ 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', () => { + const chain = (ids: string[]): Entry => ids.reduceRight( + (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined, + ) as Entry + expect(userDisablesEntry(chain(['kid', 'grp', 'root']), new Set(['grp']))).toBe(true) + expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['kid']))).toBe(true) + expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['other']))).toBe(false) }) it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => { @@ -118,9 +124,10 @@ describe('ProfileRuntime', () => { await runtime.recompose({ reloadBundles: true }) expect(runtime.layers).toHaveLength(2) expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } }) - // Provenance and conflicts follow the reloaded profile once the update holds. + // Provenance, conflicts, and the user-disabled rows follow the reloaded profile once the update holds. expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' }) expect(runtime.conflicts).toEqual(conflicts) + expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off']) }) it('runs recompositions one at a time, each from what the previous one committed', async () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6274aa0616..f4db783efe 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1398,10 +1398,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).', }, { - signature: 'userDisabledRowIds(): Set', - description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file stays an expression node when read from disk, so it is a condition, not a user decision, and is left to the composition.', + signature: 'userDisabledRowIds(): ReadonlySet', + description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.', parameters: [], - returns: 'the ids, re-read from disk on every call.', + returns: 'the ids, from the committed composition.', }, { signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 1967dba2c5..4bad54d72a 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md -README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec -README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d +README.md: ca8055907433fd01d22a032a4301970457f8d637 +README.zh.md: 9d3cc3414028ca3fae3d829869283e214c36fdc8 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 0eaf395ec9..ca80559074 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. -When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure. +When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it, or a group holding it, with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure. ### Per-preset compositions diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 60140b175c..9d3cc34140 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 -当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。 +当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。 ### 每个预设的组合 diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index e0108aa741..ddfa081017 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-agent-presets' // Type-only: the optional profile runtime and contained-failure registry the // boot glue provides, both resolved through `ctx.get`. -import type {} from '@deepseek-ai/dsh-app-boot' +import { userDisablesEntry } from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -96,7 +96,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: userDisablesEntry(entry, userDisabled) ? 'user' as const : 'composition' as const }, ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } }, }) } @@ -125,7 +125,7 @@ export class PluginInventoryGateway extends TypertRemoteService { moduleName: conflict.moduleName, enabled: true, fiberPhase: 'failed', - trust: conflict.packageName === undefined ? 'builtin' : 'external', + trust: conflict.packageName === undefined ? 'user' : 'external', ...conflict.packageName === undefined ? {} : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) }, diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 80fab20008..a3ad3444ba 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -12,8 +12,11 @@ export type PluginFiberPhase = | 'unloading' | null -/** Who supplied a Loader row: the installation itself or an installed external bundle. */ -export type PluginTrust = 'builtin' | 'external' +/** + * Who supplied a Loader row: the installation itself, an installed external + * bundle, or the user's own patch file (a row of theirs the composition left out). + */ +export type PluginTrust = 'builtin' | 'external' | 'user' /** Why a row is disabled: the composition's own gate or tombstone, or the user's patch layer. */ export type PluginDisabledBy = 'composition' | 'user' diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 6919829f70..50a4aeb22d 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -1,6 +1,6 @@ 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' @@ -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,20 @@ 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() + ctx.provide('profileRuntime', { + originOf: () => undefined, userDisabledRowIds: () => new Set([groupId]), layers: [], conflicts: [], + } as unknown as ProfileRuntime) + await ctx.loader.update(groupId, { disabled: true }) + const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id) + expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' }) + }) + it('carries each composed preset with root-fiber states mapped to phases', async () => { const { ctx, inventory } = await harness() ctx.provide('agentPresets', { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f9f2a1e662..db8680fc5a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -673,6 +673,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Record', 'Readonly', 'Set', + 'ReadonlySet', 'Uint8Array', ]) From 792a68468f619ffabecf857e70c4de1a4022fd7f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:13:41 +0800 Subject: [PATCH 4/7] fix(boot): judge a package's cordis copy by package directory The probe compared two resolved URLs, which differ for one package under a source launch (src against lib), across a symlink, and behind the proxy a packaged executable writes; it now walks each URL up to the cordis manifest, follows a dsh module proxy to its target, and compares real package directories, with a probe test for the linked and the proxied case. The README records that the probe child itself cannot run from a packaged executable. --- ...oped-fail-loud-and-package-probe.i18n.yaml | 4 +- ...boot-scoped-fail-loud-and-package-probe.md | 2 +- ...t-scoped-fail-loud-and-package-probe.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 3 +- packages/boot/app-boot/README.zh.md | 3 +- packages/boot/app-boot/src/probe.ts | 62 ++++++++++++++++--- packages/boot/app-boot/tests/probe.spec.ts | 44 ++++++++++++- 8 files changed, 106 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml index 596525dd44..dd05628ea3 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md -2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 940b500f9f0b5c531e0c0eaf849a8a8000a90a6a -2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 2205a1abfba92c06b3e65745a9d79bb09421bdb2 +2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 2b892cacf14a9f62921b0fce015f14970a2b5a06 +2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: f7004f83f417282670fb90effadf257716066e67 diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md index 940b500f9f..2b892cacf1 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md @@ -8,7 +8,7 @@ English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md) `installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place. -Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried. +Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy (judged by package directory, through links and a packaged executable's proxy) or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md index 2205a1abfb..f7004f83f4 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md @@ -8,7 +8,7 @@ Status: implemented `installFailLoud` 注册了一个进程级的 `unhandledRejection` 处理器,写出 `fatal load failure` 后退出,而 launcher 丢弃了它返回的卸载函数,于是这个处理器活到进程结束。启动期间这是对的:未处理的 rejection 就是没人会报告的加载失败。启动之后它意味着任何插件的漏网延续——社区组合包忘了 await 的一个被拒 promise——都会把每个会话一起拖下去,而且根本没有 `uncaughtException` 处理器,定时器回调里的一次同步 throw 会让进程带着 Node 的默认堆栈崩掉,没有来源。插件管理器存在的目的正是在运行时挂载三方代码,在这两个默认行为之下这是拿进程赌。 -另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema。 +另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema(按包目录判断,穿过符号链接与打包可执行文件的代理包)。 ## 决定 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ba9afc8f9d..f11c883e9c 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 2da207fb7a04db7fc73eb1b04a921d3e2ddd10dc -README.zh.md: 217881ccf67bb3213eaff4cd442ff386d5897304 +README.md: 9c91ff8dba9385567c5607ffccd7f8cfe23f038d +README.zh.md: 1106e7541f27d1d30fe1d93a4d472b1e10ea45c9 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 2da207fb7a..9c91ff8dba 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -93,7 +93,7 @@ This section explains how the outcomes above are realized and points at the code - **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that 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`. 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. 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. +- **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. @@ -157,6 +157,7 @@ These limits describe when this boot library is a poor fit or needs special care - **An external bundle's overrides are not isolated** — a patch it applies to a built-in row edits that row in place, so its effect stays when the bundle's own rows fail and it is the one thing a bundle can break outside its group. - **A conflict is decided by order, not merit** — among external bundles the earlier layer in `dsh.profile.bundles` keeps a contested id, so uninstalling that bundle lets the later one mount on the next boot; the plugin list shows which bundle lost and to whom. - **The nested-fiber audit is advisory** — a failed `ctx.inject()` continuation under a built-in entry is reported, not fatal, until shipped compositions are known clean. +- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are. ### Dev Note diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 217881ccf6..1106e7541f 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -93,7 +93,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。config 覆盖可以在已持有某行的组下重述它;同一 config 列表里出现两次、或改放到别的组下,都算声明了两次——前者会在挂载时被 Loader 拒绝,后者会被静默挪走——因此内置层启动失败、受控组合包整层排除。启动、运行时重组与 `--dump-config` 走同一个函数。 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。 -- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`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/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。 +- **探针从不在宿主内运行包。** `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`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 @@ -157,6 +157,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。 - **冲突按顺序判定,不看是非**——外部组合包之间,`dsh.profile.bundles` 里靠前的那层保住争议 id,卸掉它之后靠后的那层在下次启动时挂上;插件列表显示谁输给了谁。 - **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。 +- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。 ### 开发备注 diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index 7ff35e9b47..df26dfb39b 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -11,8 +11,8 @@ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +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' @@ -262,8 +262,7 @@ export async function probePackage(options: ProbeOptions): Promise : describeBundlePatch(options.binName, join(packageDir, declaredBundle)) const hasMain = manifest.main !== undefined || manifest.exports !== undefined const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name)) - const harnessCordis = resolveHarnessCordis() - const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis + const cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis()) const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library' @@ -305,13 +304,13 @@ export async function probePackage(options: ProbeOptions): Promise } /** - * The copy of `@deepseek-ai/cordis` this harness runs: the one this module - * resolves, which is the one every built-in plugin shares. Undefined only in - * an environment that cannot resolve it at all. + * The package directory of the `@deepseek-ai/cordis` copy this harness runs: + * the one this module resolves, which every built-in plugin shares. Undefined + * only in an environment that cannot resolve it at all. */ function resolveHarnessCordis(): string | undefined { try { - return import.meta.resolve('@deepseek-ai/cordis') + return cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis')) } catch { // Only an embedder without cordis on its module path lands here; the // harness itself always resolves its own peer. @@ -320,6 +319,53 @@ function resolveHarnessCordis(): string | undefined { } } +/** + * Whether the package's cordis is the harness's: the two package directories + * compare equal. Null when the package resolves no cordis, when the harness + * cannot resolve its own, or when no cordis manifest encloses what the + * package resolved, since none of those says the package brought a copy. + */ +function sameCordisCopy(reported: string | null, harness: string | undefined): boolean | null { + if (reported === null) return null + /* v8 ignore next -- the harness resolves its own peer; only an embedder without cordis lands here */ + if (harness === undefined) return null + const dir = cordisPackageDir(reported) + return dir === undefined ? null : dir === harness +} + +/** The manifest fields that tell a dsh module proxy from the package it stands for. */ +interface ProxyAwareManifest { + name?: unknown + dsh?: { moduleFallback?: { targets?: Record } } +} + +/** + * The directory of the `@deepseek-ai/cordis` package a resolved module URL + * belongs to, with symlinks resolved and a dsh module proxy followed to the + * package it re-exports. Two resolutions of one installed package compare + * equal this way whether they landed on `src` or `lib`, on a link or its + * target, or on the proxy a packaged executable writes for its peers; the + * URLs themselves differ in every one of those cases. + * @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave. + * @returns the real package directory, or undefined when no cordis manifest encloses the URL. + */ +export function cordisPackageDir(resolved: string): string | undefined { + let dir = dirname(fileURLToPath(resolved)) + for (;;) { + const manifestPath = join(dir, 'package.json') + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProxyAwareManifest + if (manifest.name === '@deepseek-ai/cordis') { + const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0] + return target === undefined ? realpathSync(dir) : cordisPackageDir(target) + } + } + const parent = dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + /** The cache file for one package under a profile. */ function probeCachePath(profileDir: string, packageName: string): string { return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`) diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index e86cf96e9e..5745d44dd9 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -3,12 +3,12 @@ * a child process, and the per-profile cache. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' -import { parseProbeRecord } from '../src/probe.ts' +import { cordisPackageDir, parseProbeRecord } from '../src/probe.ts' import { parseChildReport, type ChildReport } from '../src/probe-report.ts' const NAME = 'dsh-test-bin' @@ -141,6 +141,46 @@ describe('probePackage', () => { expect(own.reason).toContain('own copy of @deepseek-ai/cordis') }) + it('recognizes the harness\'s cordis through a link and through a packaged executable\'s proxy', async () => { + const harnessCordis = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis')) as string + const harnessEntry = import.meta.resolve('@deepseek-ai/cordis') + const { profileDir, installAnchor } = stage({ + 'linked': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } }, + // A cordis directory whose manifest names no package: an unknown copy, not a second one. + 'unnamed': { + main: 'export function apply() {}\n', + manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } }, + files: { + 'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ version: '0.0.0', type: 'module', main: './index.js' }), + 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', + }, + }, + 'proxied': { + main: 'export function apply() {}\n', + manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } }, + files: { + // The proxy a packaged executable writes: a manifest naming its targets, and an entry that re-exports one. + 'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ + name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', exports: { '.': './entry-0.js' }, + dsh: { moduleFallback: { targets: { '.': harnessEntry } } }, + }), + 'node_modules/@deepseek-ai/cordis/entry-0.js': `export * from ${JSON.stringify(harnessEntry)}\n`, + }, + }, + }) + // The profile links the harness's own cordis, as plain Node installs do. + mkdirSync(join(profileDir, 'node_modules', '@deepseek-ai'), { recursive: true }) + symlinkSync(harnessCordis, join(profileDir, 'node_modules', '@deepseek-ai', 'cordis'), 'dir') + const linked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'linked' }) + expect(linked).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true }) + const proxied = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'proxied' }) + expect(proxied).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true }) + const unnamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'unnamed' }) + expect(unnamed).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: null }) + // A URL no cordis manifest encloses is an unknown copy, not a different one. + expect(cordisPackageDir(`file://${tmpdir()}/nowhere/index.js`)).toBeUndefined() + }) + it('probes a package with the minimal manifest and no main export', async () => { const { profileDir, installAnchor } = stage({ 'minimal': { manifest: { version: undefined } }, From d6f5160591da63ec00664971357e5eae6e8631be Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:16:12 +0800 Subject: [PATCH 5/7] test(boot): type the id-less group row in the ownership spec --- packages/boot/app-boot/tests/compose-stack.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/compose-stack.spec.ts b/packages/boot/app-boot/tests/compose-stack.spec.ts index 6088c14e10..fd8edcbe1e 100644 --- a/packages/boot/app-boot/tests/compose-stack.spec.ts +++ b/packages/boot/app-boot/tests/compose-stack.spec.ts @@ -90,7 +90,8 @@ describe('claimLayerIds', () => { // A child of a group without an id cannot be restated: no patch can address that group. const anonymous = layer('anonymous', 'builtin', [ { insert: [ - { name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] }, + // 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' }] }, From c73d88e4e55306f7e62850a812e16a034ef20476 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:19:42 +0800 Subject: [PATCH 6/7] fix(boot): answer user-disabled entries from the profile runtime The inventory keeps dsh-app-boot an optional, type-only peer, so the walk over an entry's owning groups moves behind ProfileRuntime.userDisables(): a consumer with the runtime asks it, one without reports composition as before. The module graph and the cordis catalog follow. --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 10 ++++++++ docs/subsystems/core.zh.md | 10 ++++++++ packages/boot/app-boot/src/index.ts | 1 - packages/boot/app-boot/src/profile-runtime.ts | 24 +++++++++++-------- .../app-boot/tests/profile-runtime.spec.ts | 12 ++++++---- .../extensions/tool-cordis/src/api-catalog.ts | 6 +++++ packages/host/plugin-inventory/src/index.ts | 5 ++-- .../plugin-inventory/tests/inventory.spec.ts | 13 ++++++---- scripts/gen-cordis-catalog.ts | 1 + 13 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 2dcce27352..b27ec8b506 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e435185d0d184d66c7d29df8a31ec2946ed8fdfc -module-graph.zh.md: adf406a333885adaa980f93d4f4d6ecfd63bace7 +module-graph.md: e427d893e3b0a3cd1b2f9881a27366ccdcbe3949 +module-graph.zh.md: e94742d107f76554bbc3d6cb5f54b502cb868970 diff --git a/docs/module-graph.md b/docs/module-graph.md index e435185d0d..e427d893e3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -392,6 +392,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 @@ -1259,7 +1260,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index adf406a333..e94742d107 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -394,6 +394,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_subprocess --> pkg_launch_environment pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -1261,7 +1262,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 83f0caf22c..286af6cf6f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 4028b19620e72a3c39164444453bb5fc1f818d7f -core.zh.md: 2c75c867db13173c6db34b8a83f47c3f10f28972 +core.md: 275b95ac088260e01dacae0d24a3e0acdb935494 +core.zh.md: 23895d409d2f71021c8e38e98c423114c82ed23f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 4028b19620..275b95ac08 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -861,6 +861,16 @@ originOf(rowId: string): RowOrigin | undefined */ userDisabledRowIds(): ReadonlySet +/** + * Whether the user patch layers disable an entry: its own row id, or the + * id of a group holding it, is among {@link userDisabledRowIds}. The Loader + * disables every descendant of a disabled group, so a child's own id alone + * does not say who switched it off. + * @param entry - the Loader entry. + * @returns true when the user's patches disable the entry or one of the groups holding it. + */ +userDisables(entry: Entry): boolean + /** * Recompose the host tree from the profile's layers and the user patch files * as they stand now. The root Include re-applies the stack transactionally: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2c75c867db..23895d409d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -871,6 +871,16 @@ originOf(rowId: string): RowOrigin | undefined */ userDisabledRowIds(): ReadonlySet +/** + * Whether the user patch layers disable an entry: its own row id, or the + * id of a group holding it, is among {@link userDisabledRowIds}. The Loader + * disables every descendant of a disabled group, so a child's own id alone + * does not say who switched it off. + * @param entry - the Loader entry. + * @returns true when the user's patches disable the entry or one of the groups holding it. + */ +userDisables(entry: Entry): boolean + /** * Recompose the host tree from the profile's layers and the user patch files * as they stand now. The root Include re-applies the stack transactionally: diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 4706d81398..0e824e1473 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -65,7 +65,6 @@ export { } from './compose-stack.ts' export { ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin, - userDisablesEntry, } from './profile-runtime.ts' export { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index 2a137563dd..b5d719ee1a 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -131,6 +131,18 @@ export class ProfileRuntime extends Service { 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) + } + /** * Recompose the host tree from the profile's layers and the user patch files * as they stand now. The root Include re-applies the stack transactionally: @@ -169,16 +181,8 @@ export class ProfileRuntime extends Service { } } -/** - * Whether the user patch layers disable an entry: its own row id, or the id - * of a group holding it, is in the set. 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. - * @param userDisabled - the ids the user layers disable, from `userDisabledRowIds()`. - * @returns true when the user's patches disable the entry or one of the groups holding it. - */ -export function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet): boolean { +/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */ +function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet): boolean { for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) { if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true } diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index b9dd5de0b9..40af095e16 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/packages/boot/app-boot/tests/profile-runtime.spec.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { claimLayerIds, ProfileRuntime, userDisablesEntry, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts' +import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts' const contexts: Context[] = [] afterEach(async () => { @@ -100,13 +100,15 @@ describe('ProfileRuntime', () => { expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off']) }) - it('tells a row the user disabled through a group holding it from one the composition gates', () => { + it('tells a row the user disabled through a group holding it from one the composition gates', async () => { + const { runtime } = await harness([layer('a', 'builtin', [])]) const chain = (ids: string[]): Entry => ids.reduceRight( (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined, ) as Entry - expect(userDisablesEntry(chain(['kid', 'grp', 'root']), new Set(['grp']))).toBe(true) - expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['kid']))).toBe(true) - expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['other']))).toBe(false) + // 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 () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f4db783efe..c301fe1aba 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1403,6 +1403,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], 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', description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.', diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index ddfa081017..df3a1fbae5 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-agent-presets' // Type-only: the optional profile runtime and contained-failure registry the // boot glue provides, both resolved through `ctx.get`. -import { userDisablesEntry } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService { const entries: PluginInventoryEntry[] = [] const runtime = this.ctx.get('profileRuntime') const failures = this.ctx.get('pluginFailures') - const userDisabled = runtime?.userDisabledRowIds() ?? new Set() const listed = new Set() for (const entry of this.ctx.loader.entries()) { if (entry.options.group) continue @@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService { fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], trust: origin?.trust ?? 'builtin', ...origin === undefined ? {} : { package: packageRef(origin) }, - ...enabled ? {} : { disabledBy: userDisablesEntry(entry, userDisabled) ? '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 } }, }) } diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 50a4aeb22d..48428f4735 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -3,7 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' 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' }, @@ -160,9 +160,12 @@ describe('PluginInventoryGateway', () => { 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() - ctx.provide('profileRuntime', { - originOf: () => undefined, userDisabledRowIds: () => new Set([groupId]), layers: [], conflicts: [], - } as unknown as ProfileRuntime) + // 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' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index db8680fc5a..425ebb0f0b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -661,6 +661,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Entry', 'EntryTree', 'Exclude', 'Extract', From 5694e505482eac2ccaa62425957219e99b09b238 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:23:33 +0800 Subject: [PATCH 7/7] fix(plugin-manager): ask the runtime which rows the user disabled The view attributes a disabled row through profileRuntime.userDisables, which walks the groups holding the entry, and the manager spec stops passing the runtime option the profile runtime no longer takes. --- packages/boot/plugin-manager/src/view.ts | 4 ++-- packages/boot/plugin-manager/tests/plugin-manager.spec.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/boot/plugin-manager/src/view.ts b/packages/boot/plugin-manager/src/view.ts index 6ed89d36ea..5b0718e19b 100644 --- a/packages/boot/plugin-manager/src/view.ts +++ b/packages/boot/plugin-manager/src/view.ts @@ -129,7 +129,6 @@ function packageStatus(facts: { /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */ function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] { const failures = ctx.get('pluginFailures') - const userDisabled = runtime.userDisabledRowIds() const rows: PluginPackageRowView[] = [] const listed = new Set() for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) { @@ -140,7 +139,8 @@ function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): Plug rowId, moduleName: entry.options.name, enabled: !entry.disabled, - ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {}, + // The runtime walks the groups holding the entry: a row inside a group the user disabled is the user's doing. + ...entry.disabled ? { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const } : {}, phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state], ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } }, }) diff --git a/packages/boot/plugin-manager/tests/plugin-manager.spec.ts b/packages/boot/plugin-manager/tests/plugin-manager.spec.ts index 4e5ef782f7..9c9baa9480 100644 --- a/packages/boot/plugin-manager/tests/plugin-manager.spec.ts +++ b/packages/boot/plugin-manager/tests/plugin-manager.spec.ts @@ -218,7 +218,6 @@ async function bootProfile(staged: StagedHome, internals: Internals = {}, config loadProfile: load, compose: composeFor, rootEntry: () => rootIncludeEntry(ctx), - readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [], }) const changes: Booted['changes'] = [] const log: PluginInstallLogChunk[] = []