feat(plugin-inventory): guard critical plugins from enable/disable

Every entry now carries a protected flag. The guard is default-protect:
disabling a plugin another plugin injects breaks the dependent, and enabling
one whose service is unavailable fails the boot (dsh-tool-ralph: pending on
workflowEngine). setEnabled refuses and the UI hides the toggle for every
shipped plugin; only opt-in-bundle plugins (USER_TOGGLEABLE_PLUGINS in
required.ts) are toggleable.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 14:10:38 +08:00
parent 842483164d
commit df085ac6f8
12 changed files with 100 additions and 16 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md
2026-08-14-plugin-enable-disable-in-page.md: 18e3ce19da7e23f10a9fd9a79bbbcc907378065a 2026-08-14-plugin-enable-disable-in-page.md: 10560799d5543a3622e0a8d751012066ca36512a
2026-08-14-plugin-enable-disable-in-page.zh.md: 4726f39a04d8157759f2d67553f04d749c601ba7 2026-08-14-plugin-enable-disable-in-page.zh.md: 988b9f8fad9216f358e39966767e2e0649715a43
@@ -35,6 +35,14 @@ patch's `applyEntryPatches` target lookup.
The Web plugin-list tab (`ui-settings-plugin-inventory`) adds an enable/disable The Web plugin-list tab (`ui-settings-plugin-inventory`) adds an enable/disable
button to each expanded card, wired to the Remote, re-listing after the toggle. button to each expanded card, wired to the Remote, re-listing after the toggle.
**Guard:** every entry carries a `protected` flag. The rule is default-protect —
disabling a plugin that another plugin injects breaks the dependent, and
enabling one whose service is unavailable fails the boot (both surfaced as
`dsh-tool-ralph: pending (waiting for service: workflowEngine)` after a bad
toggle). So `setEnabled` refuses and the UI hides the toggle for every shipped
plugin; only plugins added through an opt-in bundle (`USER_TOGGLEABLE_PLUGINS`
in `src/required.ts`) are toggleable.
## Persistence caveat ## Persistence caveat
A runtime toggle alone does not survive a restart for a row enabled by a bundle A runtime toggle alone does not survive a restart for a row enabled by a bundle
@@ -28,6 +28,12 @@ Web UI 的插件列表是只读的:它展示 Loader 的条目和生命周期
Web 插件列表 tab(`ui-settings-plugin-inventory`)在每张展开卡片的详情区加启用/停用按钮, Web 插件列表 tab(`ui-settings-plugin-inventory`)在每张展开卡片的详情区加启用/停用按钮,
绑定该 Remote,切换后重新拉取列表。 绑定该 Remote,切换后重新拉取列表。
**门卫:** 每条条目带 `protected` 标记。规则默认保护——停用一个被其他插件注入的插件会破坏
依赖者,启用一个服务不可用的插件会导致启动失败(坏切换后都会表现为
`dsh-tool-ralph: pending (waiting for service: workflowEngine)`)。所以 `setEnabled`
拒绝、UI 隐藏所有随包插件的开关;只有通过 opt-in bundle 添加的插件
(`src/required.ts` 的 `USER_TOGGLEABLE_PLUGINS`)可切换。
## 持久化注意 ## 持久化注意
仅运行时 toggle 对由 bundle patch 启用的行不持久,因为 `Entry.update` 写回的是补丁后的整棵树, 仅运行时 toggle 对由 bundle patch 启用的行不持久,因为 `Entry.update` 写回的是补丁后的整棵树,
@@ -282,3 +282,10 @@
margin-top: 0.5rem; margin-top: 0.5rem;
align-self: flex-start; align-self: flex-start;
} }
.required {
margin-top: 0.5rem;
color: inherit;
opacity: 0.7;
font-size: 0.85em;
}
@@ -196,15 +196,19 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
</div> </div>
) : null} ) : null}
</dl> </dl>
<button {entry.protected ? (
className={css.toggle} <p className={css.required}>{t('required')}</p>
type="button" ) : (
disabled={busy === entry.entryId} <button
aria-busy={busy === entry.entryId || undefined} className={css.toggle}
onClick={() => { toggle(entry.entryId, !entry.enabled) }} type="button"
> disabled={busy === entry.entryId}
{busy === entry.entryId ? t('toggling') : entry.enabled ? t('disable') : t('enable')} aria-busy={busy === entry.entryId || undefined}
</button> onClick={() => { toggle(entry.entryId, !entry.enabled) }}
>
{busy === entry.entryId ? t('toggling') : entry.enabled ? t('disable') : t('enable')}
</button>
)}
</div> </div>
) : null} ) : null}
</li> </li>
@@ -23,6 +23,7 @@ export const zh = {
enable: '启用', enable: '启用',
disable: '停用', disable: '停用',
toggling: '切换中…', toggling: '切换中…',
required: '应用必需插件,不可切换',
} satisfies Record<string, string> } satisfies Record<string, string>
/** Plugin inventory locale key union. */ /** Plugin inventory locale key union. */
@@ -51,4 +52,5 @@ export const en = {
enable: 'Enable', enable: 'Enable',
disable: 'Disable', disable: 'Disable',
toggling: 'Toggling…', toggling: 'Toggling…',
required: 'Required by the app; cannot be toggled',
} satisfies Record<PluginInventoryLocaleKey, string> } satisfies Record<PluginInventoryLocaleKey, string>
+3 -1
View File
@@ -4,7 +4,9 @@ English | [中文](README.zh.md)
Host projection of the current Cordis Loader tree with per-plugin enable/disable. `PluginInventoryGateway` registers the `pluginInventory` service and publishes two generated direct Remotes: `pluginInventory/list` and `pluginInventory/setEnabled`. `list` reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. Host projection of the current Cordis Loader tree with per-plugin enable/disable. `PluginInventoryGateway` registers the `pluginInventory` service and publishes two generated direct Remotes: `pluginInventory/list` and `pluginInventory/setEnabled`. `list` reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick). Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick).
Every entry carries a `protected` flag. The guard is default-protect: every shipped plugin is required by the application (disabling one that another plugin injects breaks the dependent; enabling one whose service is unavailable fails the boot), so `setEnabled` refuses them and the UI hides the toggle. Only plugins a deployment adds through an opt-in bundle (`USER_TOGGLEABLE_PLUGINS` in `src/required.ts`) are toggleable. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.
+3 -1
View File
@@ -4,7 +4,9 @@
当前 Cordis Loader 树的 Host 投影,带逐插件启用/停用。`PluginInventoryGateway` 注册 `pluginInventory` 服务,并发布两个由 Typert 生成的直接 Remote:`pluginInventory/list` 与 `pluginInventory/setEnabled`。`list` 直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。 当前 Cordis Loader 树的 Host 投影,带逐插件启用/停用。`PluginInventoryGateway` 注册 `pluginInventory` 服务,并发布两个由 Typert 生成的直接 Remote:`pluginInventory/list` 与 `pluginInventory/setEnabled`。`list` 直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。
每条条目带 `protected` 标记。守卫默认保护:所有随包插件都是应用必需(停用一个被其他插件注入的插件会破坏依赖者;启用一个服务不可用的插件会导致启动失败),所以 `setEnabled` 拒绝它们、UI 隐藏开关。只有部署通过 opt-in bundle 添加的插件(`src/required.ts` 的 `USER_TOGGLEABLE_PLUGINS`)可切换。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。
@@ -7,6 +7,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
// Typert-generated ./typert and ./remote artifacts import Zod at runtime. // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
import type {} from 'zod' import type {} from 'zod'
import { persistPluginDisabled } from './persist.ts' import { persistPluginDisabled } from './persist.ts'
import { isRequiredPlugin } from './required.ts'
import type { import type {
PluginEntryId, PluginEntryId,
PluginFiberPhase, PluginFiberPhase,
@@ -64,6 +65,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
entryId: pluginEntryId(entry.id), entryId: pluginEntryId(entry.id),
moduleName: entry.options.name, moduleName: entry.options.name,
enabled: !entry.disabled, enabled: !entry.disabled,
protected: isRequiredPlugin(entry.options.name),
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
}) })
} }
@@ -87,6 +89,9 @@ export class PluginInventoryGateway extends TypertRemoteService {
if (entry === undefined) { if (entry === undefined) {
throw new Error(`plugin entry ${String(entryId)} not found`) throw new Error(`plugin entry ${String(entryId)} not found`)
} }
if (isRequiredPlugin(entry.options.name)) {
throw new Error(`plugin ${String(entryId)} is required by the application and cannot be toggled`)
}
const rowId = entry.options.id const rowId = entry.options.id
await this.ctx.loader.update(entryId, { disabled: !enabled }) await this.ctx.loader.update(entryId, { disabled: !enabled })
if (this.ctx.baseUrl !== undefined) { if (this.ctx.baseUrl !== undefined) {
@@ -0,0 +1,30 @@
/**
* Which Loader plugins the running application requires and must not be toggled.
*
* The guard is a default-protect rule: every shipped/known plugin is required by
* the application, so toggling it can tear the process down — disabling a plugin
* that another plugin injects breaks the dependent, and enabling one whose
* service is unavailable fails the boot. Only plugins a deployment explicitly
* adds through an opt-in bundle (the "user/extra" plugins) are safe to enable or
* disable. Add every new opt-in bundle's plugin module names to
* {@link USER_TOGGLEABLE_PLUGINS}.
* @module @deepseek-ai/dsh-plugin-inventory/required
*/
/**
* Plugin module names a deployment may enable or disable. Every other module is
* required by the application and surfaced as `protected`. Extend this set when
* a new opt-in bundle adds plugins the UI should let the user toggle.
*/
const USER_TOGGLEABLE_PLUGINS = new Set([
'@deepseek-ai/dsh-image-recognition',
'@deepseek-ai/dsh-image-recognition-http',
'@deepseek-ai/dsh-tool-image-recognition',
// Test seam: a `cordis:` builtin the unit tests use as a toggleable entry.
'cordis:user-toggleable',
])
/** Whether a Loader module is required by the application and must not be toggled. */
export function isRequiredPlugin(moduleName: string): boolean {
return !USER_TOGGLEABLE_PLUGINS.has(moduleName)
}
@@ -19,6 +19,8 @@ export interface PluginInventoryEntry {
readonly moduleName: string readonly moduleName: string
/** Effective Loader enablement, including disabled ancestor groups. */ /** Effective Loader enablement, including disabled ancestor groups. */
readonly enabled: boolean readonly enabled: boolean
/** Whether the running application requires this plugin and forbids toggling it. */
readonly protected: boolean
readonly fiberPhase: PluginFiberPhase readonly fiberPhase: PluginFiberPhase
} }
@@ -25,6 +25,9 @@ async function harness(): Promise<{
await ctx.plugin(Loader) await ctx.plugin(Loader)
ctx.loader.builtins.active = activePlugin ctx.loader.builtins.active = activePlugin
ctx.loader.builtins.pending = pendingPlugin ctx.loader.builtins.pending = pendingPlugin
// `cordis:` builtins the toggle tests create; they resolve without a package install.
ctx.loader.builtins['user-toggleable'] = activePlugin
ctx.loader.builtins['host-webserver'] = activePlugin
await ctx.plugin(PluginInventoryGateway) await ctx.plugin(PluginInventoryGateway)
const inventory = ctx.get('pluginInventory') as PluginInventoryGateway const inventory = ctx.get('pluginInventory') as PluginInventoryGateway
return { ctx, inventory } return { ctx, inventory }
@@ -43,20 +46,29 @@ describe('PluginInventoryGateway', () => {
]) ])
}) })
it('setEnabled toggles the Loader entry live', async () => { it('setEnabled toggles a user-toggleable Loader entry live', async () => {
const { ctx, inventory } = await harness() const { ctx, inventory } = await harness()
const id = await ctx.loader.create({ name: 'cordis:active' }) as PluginEntryId const id = await ctx.loader.create({ name: 'cordis:user-toggleable' }) as PluginEntryId
await inventory.setEnabled(id, false) await inventory.setEnabled(id, false)
expect(inventory.list().entries.find(entry => entry.entryId === id)).toEqual({ expect(inventory.list().entries.find(entry => entry.entryId === id)).toEqual({
entryId: id, entryId: id,
moduleName: 'cordis:active', moduleName: 'cordis:user-toggleable',
enabled: false, enabled: false,
protected: false,
fiberPhase: null, fiberPhase: null,
}) })
await inventory.setEnabled(id, true) await inventory.setEnabled(id, true)
expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(true) expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(true)
}) })
it('setEnabled refuses a required plugin', async () => {
const { ctx, inventory } = await harness()
const id = await ctx.loader.create({ name: 'cordis:host-webserver' }) as PluginEntryId
expect(inventory.list().entries.find(entry => entry.entryId === id)?.protected).toBe(true)
await expect(inventory.setEnabled(id, false)).rejects.toThrow(/required by the application/)
expect(inventory.list().entries.find(entry => entry.entryId === id)?.enabled).toBe(true)
})
it('projects current non-group Loader entries without a second cache', async () => { it('projects current non-group Loader entries without a second cache', async () => {
const { ctx, inventory } = await harness() const { ctx, inventory } = await harness()
const activeId = await ctx.loader.create({ name: 'cordis:active' }) const activeId = await ctx.loader.create({ name: 'cordis:active' })
@@ -74,18 +86,21 @@ describe('PluginInventoryGateway', () => {
entryId: activeId, entryId: activeId,
moduleName: 'cordis:active', moduleName: 'cordis:active',
enabled: true, enabled: true,
protected: true,
fiberPhase: 'active', fiberPhase: 'active',
}, },
{ {
entryId: pendingId, entryId: pendingId,
moduleName: 'cordis:pending', moduleName: 'cordis:pending',
enabled: true, enabled: true,
protected: true,
fiberPhase: 'pending', fiberPhase: 'pending',
}, },
{ {
entryId: disabledId, entryId: disabledId,
moduleName: 'cordis:not-installed', moduleName: 'cordis:not-installed',
enabled: false, enabled: false,
protected: true,
fiberPhase: null, fiberPhase: null,
}, },
])) ]))
@@ -95,6 +110,7 @@ describe('PluginInventoryGateway', () => {
entryId: activeId, entryId: activeId,
moduleName: 'cordis:active', moduleName: 'cordis:active',
enabled: false, enabled: false,
protected: true,
fiberPhase: null, fiberPhase: null,
}) })