From ffffac3cba7d0df0b9ce907d068cedecd83421f1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 15 Sep 2026 18:09:28 +0800 Subject: [PATCH 1/5] fix(plugin-manager): approve blocked dependency builds before retrying --- ...urrent-profile-plugin-management.i18n.yaml | 4 +- ...09-14-current-profile-plugin-management.md | 2 + ...14-current-profile-plugin-management.zh.md | 2 + docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 2 +- docs/subsystems/boot.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 7 ++ docs/tool-catalog.zh.md | 7 ++ packages/api/remotes/src/client/index.ts | 2 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 + packages/boot/plugin-manager/README.zh.md | 2 + .../boot/plugin-manager/src/build-approval.ts | 44 ++++++++++++ packages/boot/plugin-manager/src/index.ts | 14 +++- packages/boot/plugin-manager/src/tools.ts | 6 +- packages/boot/plugin-manager/src/types.ts | 6 ++ .../tests/build-approval.spec.ts | 59 ++++++++++++++++ .../boot/plugin-manager/tests/manager.spec.ts | 69 ++++++++++++++++++- .../boot/plugin-manager/tests/tools.spec.ts | 2 + .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 2 + .../ui-settings-plugin-inventory/README.zh.md | 2 + .../src/client/locales.ts | 6 ++ .../src/client/management.tsx | 15 +++- .../tests/components.client.spec.tsx | 18 +++++ .../extensions/tool-cordis/src/api-catalog.ts | 6 +- .../plugin-manager/tool-schemas.expected.json | 7 ++ 30 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 packages/boot/plugin-manager/src/build-approval.ts create mode 100644 packages/boot/plugin-manager/tests/build-approval.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml index b1a82211cc..8c766a0804 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.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-14-current-profile-plugin-management.md -2026-09-14-current-profile-plugin-management.md: f92337de4b3b38442ce6e875ebcd1b8c8ccbb01a -2026-09-14-current-profile-plugin-management.zh.md: 645775fd8b2735d46887e1ddbea1ebeb66b616a1 +2026-09-14-current-profile-plugin-management.md: 3f1fca65201385b01cdccbdca34f5b45b896fc3e +2026-09-14-current-profile-plugin-management.zh.md: 125b0012ddf9d24fda34e82722e551588ed75761 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md index f92337de4b..3f1fca6520 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md @@ -20,6 +20,8 @@ This extends the [profile bundle composition decision](2026-08-05-profile-plugin CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate. +Build approvals update pnpm 11's unresolved `allowBuilds` entries under the same profile lock and preserve unrelated YAML. They persist by exact package name rather than applying an unrestricted script policy. The retry accepts only names still pending, so stale requests cannot override a subsequent denial. Package cleanup leaves the approval settings intact; a later retry can use them without retaining partially installed dependencies. The service reports policy-only changes and injects them through the existing management notices. + ## Alternatives considered **Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation. diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md index 645775fd8b..125b0012dd 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md @@ -20,6 +20,8 @@ profile 文件保持为持久状态:条目开关只修改最后一条符合条 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。 +构建审批在同一个 profile 写锁内更新 pnpm 11 尚未决定的 `allowBuilds` 条目,并保留无关 YAML。授权按准确包名持久化,不采用无条件允许脚本的策略。重试只接受仍在待审批列表中的包名,因此过期请求不能覆盖后续拒绝。包清理保留审批设置,后续重试可以复用授权而不必保留部分安装的依赖。service 报告仅涉及策略的变化,并通过现有管理通知注入。 + ## 考虑过的替代方案 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 45e4806afc..cadb5b1f59 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 219373c5899b374167ad36ac4726eddbf0c4096d +config-catalog.md: 6349bdb010910086a82d7d47c5a300f2157a7436 config-catalog.zh.md: a9a07b97da99ee5ce6b5666255dd07ea0080f5ae diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 219373c589..6349bdb010 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1821,7 +1821,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:21`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:22`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 5a06f95947..295ad68e93 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.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/boot.md -boot.md: b2507672c3728ebd426b089f4de33f2a83fcabee -boot.zh.md: 1395b6de08f871e757a167ae3d655bcf41e36de8 +boot.md: bf47e32055f921579c0e4e34f91a25dbef28f97d +boot.zh.md: ac0a8b2ba5bb1222967d4b1a5136aaa8eb2a5b4e diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index b2507672c3..bf47e32055 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -86,7 +86,7 @@ Manage profile files and apply their declared reload lifecycle. /** Install a package using the same pnpm implementation as dsh plugin. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Whether to activate the installed bundle; defaults to true. + * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1395b6de08..ac0a8b2ba5 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -86,7 +86,7 @@ Manage profile files and apply their declared reload lifecycle. /** Install a package using the same pnpm implementation as dsh plugin. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Whether to activate the installed bundle; defaults to true. + * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index ddcbe9e3c8..2d4be95536 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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/tool-catalog.md -tool-catalog.md: 999ca77d47c12d9b1d5cc749fc854069e597f767 -tool-catalog.zh.md: 32db6d25e5377b882de62c034343c1feb66abd16 +tool-catalog.md: 14fff202b77c45afe5ec25276032b6e6a589da33 +tool-catalog.zh.md: f9965bfb320ff61b9cef333287954898e628336b diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 999ca77d47..14fff202b7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -77,6 +77,13 @@ List plugins or bundles in the current profile, enable or disable them, install "type": "boolean", "description": "Required for set operations; defaults to true for installation." }, + "approvedBuilds": { + "type": "array", + "description": "For install_bundle: package names from pendingBuilds whose install scripts the user explicitly approved. Permission persists for this profile.", + "items": { + "type": "string" + } + }, "offset": { "type": "number", "description": "Zero-based list offset; defaults to 0." diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 32db6d25e5..f9965bfb32 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -81,6 +81,13 @@ "type": "boolean", "description": "Required for set operations; defaults to true for installation." }, + "approvedBuilds": { + "type": "array", + "description": "For install_bundle: package names from pendingBuilds whose install scripts the user explicitly approved. Permission persists for this profile.", + "items": { + "type": "string" + } + }, "offset": { "type": "number", "description": "Zero-based list offset; defaults to 0." diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index fbe5b892f2..015a8f2667 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -22,7 +22,7 @@ import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -export type { PluginInfo, BundleInfo, ChangeResult, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types' +export type { PluginInfo, BundleInfo, ChangeResult, InstallBundleOptions, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-agent-presets/remote' diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index bbd1c9c510..8d76e6a8cf 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/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/plugin-manager/README.md -README.md: 935186b6c8e59de8324a0240ce956d7a4886272d -README.zh.md: a85ac2b18a9ed2a1cdb0c7003bd3bc107b28906c +README.md: 33f942f88e4854de47e96e157ed221eea58879fa +README.zh.md: 5f31804f70cefdc69f734ce7125a486053dd745e diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 935186b6c8..33f942f88e 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -37,6 +37,8 @@ Enable the tool explicitly in the profile patch; agents using a preset also need A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. +When pnpm 11 blocks dependency scripts, the failed installation reports pending package names. Web offers **Allow these scripts and retry**; the tool accepts the same names through `approvedBuilds` on `install_bundle`. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Installation cleanup retains these decisions. Retry preserves the original activation choice. + ### Configuration | Field | Default | Meaning | diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index a85ac2b18a..5f31804f70 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -37,6 +37,8 @@ kind: "package-reference" 插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 +pnpm 11 阻止依赖脚本时,安装失败结果列出待审批包名。Web 提供“允许这些脚本并重试”;工具通过 `install_bundle` 的 `approvedBuilds` 接收相同包名。授权按包名保存在当前 profile,允许脚本以宿主用户权限执行命令,并在再次安装失败后保留。该操作只能批准当前尚未决定的包名,不能覆盖已有拒绝或通配符规则。安装清理保留这些决定,重试沿用原来的启用选项。 + ### 配置 | 字段 | 默认值 | 含义 | diff --git a/packages/boot/plugin-manager/src/build-approval.ts b/packages/boot/plugin-manager/src/build-approval.ts new file mode 100644 index 0000000000..be8f38b1e1 --- /dev/null +++ b/packages/boot/plugin-manager/src/build-approval.ts @@ -0,0 +1,44 @@ +/** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isMap, isScalar, parseDocument } from 'yaml' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +async function readPolicy(dir: string) { + let text: string + try { text = await readFile(join(dir, 'pnpm-workspace.yaml'), 'utf8') } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + text = '{}\n' + } + const document = parseDocument(text) + if (document.errors[0] !== undefined) throw document.errors[0] + if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml must be a YAML mapping') + const builds = document.get('allowBuilds') + if (builds !== undefined && !isMap(builds)) throw new Error('allowBuilds must be a YAML mapping') + const pending = isMap(builds) ? builds.items.flatMap(({ key, value }) => + isScalar(key) && typeof key.value === 'string' && !/[*?]/.test(key.value) + && isScalar(value) && value.value === 'set this to true or false' ? [key.value] : []) : [] + return { document, pending } +} + +/** Read package names left undecided by pnpm 11, including after installation cleanup. + * @param dir Current profile directory. + * @returns Exact package names awaiting a build decision; wildcard rules are excluded. + */ +export async function readPendingBuilds(dir: string): Promise { + return (await readPolicy(dir)).pending +} + +/** Persist approval without running scripts; the caller holds the profile manifest lock. + * @param dir Current profile directory. + * @param names Explicit package names from the pending build list. + * @throws If any requested name is no longer pending; no approvals are written. + */ +export async function approveBuilds(dir: string, names: readonly string[]): Promise { + const { document, pending } = await readPolicy(dir) + if (names.some(name => !pending.includes(name))) throw new Error('Build approval changed; retry installation to refresh the pending packages') + if (names.length === 0) return + for (const name of names) document.setIn(['allowBuilds', name], true) + await writeFileAtomic(join(dir, 'pnpm-workspace.yaml'), String(document), { mode: 0o600 }) +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index c9954233a6..a960ef7c7f 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts' import { writePluginEnabled } from './patch.ts' +import { approveBuilds, readPendingBuilds } from './build-approval.ts' import type { BundleInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginEntryId, PluginInfo } from './types.ts' export type * from './types.ts' @@ -190,18 +191,25 @@ export class PluginManager extends TypertRemoteService { /** Install a package using the same pnpm implementation as dsh plugin. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Whether to activate the installed bundle; defaults to true. + * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise { return this.change(async (result) => { if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec') + if (options?.approvedBuilds !== undefined) { + await approveBuilds(this.profile.dir, options.approvedBuilds) + result.approvedBuilds = options.approvedBuilds + } const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} let name: string try { result.packageResult = await this.runPnpm(['add', spec]) - if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output) + if (result.packageResult.exitCode !== 0) { + result.pendingBuilds = await readPendingBuilds(this.profile.dir) + throw new Error(result.packageResult.output) + } const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} const installed = Object.keys(after).filter(name => before[name] !== after[name]) // Registry retries can retain the saved range after a partial installation. @@ -351,7 +359,7 @@ export class PluginManager extends TypertRemoteService { } private diskState(): string { - return ['package.json', 'cordis.patch.yml'].map((file) => { + return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => { try { return readFileSync(join(this.profile.dir, file), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index fec35bec34..67eedff9ad 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -19,6 +19,7 @@ export function apply(ctx: Context): void { action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' }, target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' }, enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' }, + approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: package names from pendingBuilds whose install scripts the user explicitly approved. Permission persists for this profile.' }, offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' }, limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' }, }, @@ -50,7 +51,10 @@ export function apply(ctx: Context): void { } case 'install_bundle': if (args.target === undefined) throw new Error('target package spec is required') - return JSON.stringify(await manager.installBundle(args.target, args.enabled === undefined ? {} : { enabled: args.enabled })) + return JSON.stringify(await manager.installBundle(args.target, { + ...args.enabled === undefined ? {} : { enabled: args.enabled }, + ...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds }, + })) case 'remove_bundle': if (args.target === undefined) throw new Error('target bundle name is required') return JSON.stringify(await manager.removeBundle(args.target)) diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index e23eb5d2e2..ab2ff05a3b 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -51,9 +51,15 @@ export interface ChangeResult { /** One cleanup attempt, restricted to a newly added dependency. */ cleanup?: { name: string; packageResult?: PackageResult; error?: ManagementError } packageResult?: PackageResult + /** Exact package names awaiting explicit script approval in the profile's pnpm settings. */ + pendingBuilds?: string[] + /** Package script permissions saved before this installation attempt. */ + approvedBuilds?: string[] } /** Bundle installation defaults to activation. */ export interface InstallBundleOptions { enabled?: boolean + /** Explicitly allow these pending packages' scripts for this profile, then retry installation. */ + approvedBuilds?: string[] } diff --git a/packages/boot/plugin-manager/tests/build-approval.spec.ts b/packages/boot/plugin-manager/tests/build-approval.spec.ts new file mode 100644 index 0000000000..77f209b20f --- /dev/null +++ b/packages/boot/plugin-manager/tests/build-approval.spec.ts @@ -0,0 +1,59 @@ +/** Pending script permissions survive cleanup and preserve unrelated workspace settings. */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { expect, it, onTestFinished } from 'vitest' +import { parse } from 'yaml' +import { approveBuilds, readPendingBuilds } from '../src/build-approval.ts' + +function fixture(text?: string) { + const dir = mkdtempSync(join(tmpdir(), 'build-approval-')) + onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) + const filename = join(dir, 'pnpm-workspace.yaml') + if (text !== undefined) writeFileSync(filename, text) + return { dir, filename } +} + +it('approves only named pending packages and preserves comments, decisions and settings', async () => { + const { dir, filename } = fixture('# profile settings\nnodeLinker: hoisted\nallowBuilds:\n native: set this to true or false\n "@scope/other": set this to true or false\n trusted: true\n denied: false\n "@scope/*": set this to true or false\n') + expect(await readPendingBuilds(dir)).toEqual(['native', '@scope/other']) + await approveBuilds(dir, ['native']) + const text = readFileSync(filename, 'utf8') + expect(text).toContain('# profile settings') + expect(parse(text)).toMatchObject({ nodeLinker: 'hoisted', allowBuilds: { native: true, trusted: true, denied: false } }) + expect(await readPendingBuilds(dir)).toEqual(['@scope/other']) +}) + +it.each(['missing', 'denied', '*', '--all'])('rejects an unlisted approval atomically: %s', async (name) => { + const original = 'allowBuilds:\n native: set this to true or false\n denied: false\n' + const { dir, filename } = fixture(original) + await expect(approveBuilds(dir, ['native', name])).rejects.toThrow('Build approval changed') + expect(readFileSync(filename, 'utf8')).toBe(original) +}) + +it('preserves pnpm file dependency selectors verbatim', async () => { + const name = '@scope/addon@file:../local addon' + const { dir, filename } = fixture(`allowBuilds:\n '${name}': set this to true or false\n`) + expect(await readPendingBuilds(dir)).toEqual([name]) + await approveBuilds(dir, [name]) + expect(parse(readFileSync(filename, 'utf8'))).toEqual({ allowBuilds: { [name]: true } }) +}) + +it.each([undefined, '{}\n', 'nodeLinker: hoisted\n', 'allowBuilds: {}\n'])('has no pending approval without pnpm placeholders: %s', async (text) => { + const { dir } = fixture(text) + expect(await readPendingBuilds(dir)).toEqual([]) + await approveBuilds(dir, []) +}) + +it.each(['[', '[]\n', 'allowBuilds: false\n'])('rejects malformed workspace settings without rewriting them: %s', async (text) => { + const { dir, filename } = fixture(text) + await expect(readPendingBuilds(dir)).rejects.toThrow() + await expect(approveBuilds(dir, ['native'])).rejects.toThrow() + expect(readFileSync(filename, 'utf8')).toBe(text) +}) + +it('reports unreadable workspace settings', async () => { + const { dir, filename } = fixture() + mkdirSync(filename) + await expect(readPendingBuilds(dir)).rejects.toThrow() +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 4df4cbc144..9c13922d70 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1,5 +1,5 @@ /** Persistent manager behavior through a real profile Include and Loader. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' @@ -17,6 +17,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { Group } from '@deepseek-ai/cordis-plugin-loader' import * as operations from '../src/operations.ts' +import { parse } from 'yaml' async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) { const home = mkdtempSync(join(tmpdir(), 'plugin-manager-')) @@ -139,6 +140,72 @@ it('installs only valid bundle declarations and honors installation without acti expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true) }) +it('cleans a blocked installation and retries only after explicit profile build approval', async () => { + const { manager, dir, bundle } = await fixture() + const policy = join(dir, 'pnpm-workspace.yaml') + const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const manifest = readProfileManifest('test', dir) + const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') } + if (args[0] === 'remove') { + delete manifest.dependencies?.addon + } else { + manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' } + if (run.mock.calls.length === 1) { + writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n') + completion.exitCode = 1 + completion.output = 'ERR_PNPM_IGNORED_BUILDS' + } else { + expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } }) + bundle('addon', []) + } + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return completion + }) + onTestFinished(() => { run.mockRestore() }) + expect(await manager.installBundle('addon')).toMatchObject({ + application: 'failed', pendingBuilds: ['native'], cleanup: { name: 'addon' }, + }) + expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon') + expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } }) + expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false }) + expect(run).toHaveBeenCalledTimes(2) + expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ application: 'applied', changed: true }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon') +}) + +it('retains approved policy and reports it as changed when the registry fails before adding a dependency', async () => { + const { manager, dir } = await fixture() + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'allowBuilds:\n native: set this to true or false\n') + const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'registry unavailable', truncated: false, logPath: '/log' }) + onTestFinished(() => { run.mockRestore() }) + expect(await manager.installBundle('addon', { approvedBuilds: ['native'] })).toMatchObject({ + changed: true, application: 'failed', pendingBuilds: [], approvedBuilds: ['native'], error: { diagnostic: 'registry unavailable' }, + }) + expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } }) +}) + +it('runs a real pnpm dependency script only after approval and cleanup retry', async () => { + const { manager, dir, profile } = await fixture('startup') + const addon = join(profile.cwd, 'addon') + mkdirSync(addon) + writeFileSync(join(addon, 'package.json'), JSON.stringify({ name: 'approval-fixture-addon', version: '1.0.0', + scripts: { install: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(addon, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n') + writeFileSync(join(addon, 'cordis.patch.yml'), '[]\n') + writeFileSync(join(dir, 'package.json'), '{"name":"approval-fixture","private":true}\n') + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n') + const blocked = await manager.installBundle('file:./addon', { enabled: false }) + expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', cleanup: { name: 'approval-fixture-addon' } }) + expect(blocked.pendingBuilds).toHaveLength(1) + const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt') + expect(existsSync(built)).toBe(false) + expect(readProfileManifest('test', dir).dependencies?.['approval-fixture-addon']).toBeUndefined() + const allowed = await manager.installBundle('file:./addon', { enabled: false, approvedBuilds: blocked.pendingBuilds! }) + expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } }) + expect(readFileSync(built, 'utf8')).toBe('built') +}) + it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => { const { manager, dir, ctx } = await fixture() const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index f29c54199c..c0e0e8f951 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -53,6 +53,8 @@ it('forwards all mutation actions and renders the returned outcome', async () => expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', {}) await call({ action: 'install_bundle', target: 'bundle', enabled: false }) expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { enabled: false }) + await call({ action: 'install_bundle', target: 'bundle', approvedBuilds: ['native'] }) + expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { approvedBuilds: ['native'] }) expect(resultText(await call({ action: 'remove_bundle', target: 'bundle' }))).toContain('"application":"failed"') expect(manager.removeBundle).toHaveBeenCalledWith('bundle') }) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 52bc4cbd9f..97aa0a865c 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-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/client/ui-settings-plugin-inventory/README.md -README.md: fb8a4d995249a97fcad629db22c39950bfe89f54 -README.zh.md: 58b9590682f0397c5344f599267f7fa749e44330 +README.md: ed0fe35984958486528322520c2b6c50b2985051 +README.zh.md: 5c5368a26fff78b6ec6c3c0923587e7577a8431d diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index fb8a4d9952..ed0fe35984 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -27,6 +27,8 @@ The **Plugin list** tab lets Web users inspect plugins without changing their co Open the Plugins section in Settings and select the **Plugin list** tab to inspect the Host's plugin inventory. The tab reads no Remote during plugin activation — selecting it for the first time mounts the component and lazily calls `ctx.remote.pluginInventory.list()` through `api-remotes`. +A failed installation with pending pnpm build permissions offers **Allow these scripts and retry**. The action displays the exact package names and persistent permission scope, then retries the original package spec and activation choice. Closing the page grants no permission. + ### Reading a card Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; enabled entries also show a colored root-fiber status dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 58b9590682..5c5368a26f 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 打开设置中的「插件」分区并选择**插件列表**标签页,即可查看宿主的插件清单。插件激活期间不会读取 Remote——首次选择该标签页时才挂载组件,并通过 `api-remotes` 懒调用 `ctx.remote.pluginInventory.list()`。 +安装失败且存在待审批的 pnpm 构建权限时,页面提供“允许这些脚本并重试”。该操作展示准确包名和持久授权范围,再使用原来的包规格及启用选项重试。关闭页面不会授予权限。 + ### 阅读卡片 每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 6ea030e651..36f0858b3c 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -15,6 +15,9 @@ export const zh = { cleanup: '清理新增依赖', remainingDependencies: '剩余新增依赖', existingFailures: '其他诊断', bundles: '组合包', packageSpec: 'npm 包名称或本地路径', install: '安装', remove: '删除', enableAfterInstall: '安装后启用', applying: '正在应用更改…', + buildApproval: '构建脚本审批', + buildApprovalDescription: '以下依赖的安装脚本尚未获准运行。允许后,脚本可使用你的系统权限执行命令,授权保存在当前 profile 中。确认信任这些依赖后再重试。', + approveBuildsAndRetry: '允许这些脚本并重试', operationFailed: '操作失败', applied: '已生效', 'restart-required': '重启后生效', overridden: '被其他配置覆盖', pluginSwitch: '启停插件 {name}', bundleSwitch: '启停组合包 {name}', tab: '插件列表', @@ -76,6 +79,9 @@ export const en = { cleanup: 'New dependency cleanup', remainingDependencies: 'Remaining new dependencies', existingFailures: 'Other diagnostics', bundles: 'Bundles', packageSpec: 'npm package name or local path', install: 'Install', remove: 'Remove', enableAfterInstall: 'Enable after installation', applying: 'Applying changes…', + buildApproval: 'Build script approval', + buildApprovalDescription: 'These dependencies need permission to run installation scripts. Approval lets scripts run commands with your system permissions and is saved for this profile. Retry only if you trust these dependencies.', + approveBuildsAndRetry: 'Allow these scripts and retry', operationFailed: 'Failed', applied: 'Applied', 'restart-required': 'Restart required', overridden: 'Overridden by another configuration', pluginSwitch: 'Toggle plugin {name}', bundleSwitch: 'Toggle bundle {name}', tab: 'Plugin list', diff --git a/packages/client/ui-settings-plugin-inventory/src/client/management.tsx b/packages/client/ui-settings-plugin-inventory/src/client/management.tsx index 9d9033cce9..81b777bb98 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/management.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/management.tsx @@ -1,6 +1,6 @@ /** Minimal profile package controls over the shared manager Remote. */ import { useEffect, useRef, useState } from 'react' -import type { BundleInfo, ChangeResult, PluginEntryId, PluginInfo } from '@deepseek-ai/dsh-api-remotes/client' +import type { BundleInfo, ChangeResult, InstallBundleOptions, PluginEntryId, PluginInfo } from '@deepseek-ai/dsh-api-remotes/client' import type { PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx' import css from './PluginInventorySettingsTab.module.css' @@ -10,7 +10,7 @@ export interface PluginManagement { listBundles(): Promise setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise setBundleEnabled(name: string, enabled: boolean): Promise - installBundle(spec: string, options: { enabled: boolean }): Promise + installBundle(spec: string, options: InstallBundleOptions): Promise removeBundle(name: string): Promise } @@ -61,6 +61,8 @@ export function BundleManager({ manager, state, t }: { }) { const [spec, setSpec] = useState('') const [enabled, setEnabled] = useState(true) + const result = state.result + const pendingBuilds = result?.pendingBuilds return

{t('bundles')}

{ @@ -86,6 +88,15 @@ export function BundleManager({ manager, state, t }: { {state.result.remainingDependencies?.length ? {t('remainingDependencies')}: {state.result.remainingDependencies.join(', ')} : null} {state.result.warnings?.length ? {t('existingFailures')}: {state.result.warnings.join('\n')} : null}

} + {result !== undefined && pendingBuilds !== undefined && pendingBuilds.length > 0 ?
+

{t('buildApprovalDescription')}

+
    {pendingBuilds.map(name =>
  • {name}
  • )}
+ +
: null}
    {state.bundles.map(bundle =>
  • {bundle.name} {bundle.version}