Kaynağa Gözat

fix(plugin-manager): approve blocked dependency builds before retrying

Turtle 1 hafta önce
ebeveyn
işleme
ffffac3cba
30 değiştirilmiş dosya ile 283 ekleme ve 25 silme
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  4. 1 1
      docs/config-catalog.i18n.yaml
  5. 1 1
      docs/config-catalog.md
  6. 2 2
      docs/subsystems/boot.i18n.yaml
  7. 1 1
      docs/subsystems/boot.md
  8. 1 1
      docs/subsystems/boot.zh.md
  9. 2 2
      docs/tool-catalog.i18n.yaml
  10. 7 0
      docs/tool-catalog.md
  11. 7 0
      docs/tool-catalog.zh.md
  12. 1 1
      packages/api/remotes/src/client/index.ts
  13. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  14. 2 0
      packages/boot/plugin-manager/README.md
  15. 2 0
      packages/boot/plugin-manager/README.zh.md
  16. 44 0
      packages/boot/plugin-manager/src/build-approval.ts
  17. 11 3
      packages/boot/plugin-manager/src/index.ts
  18. 5 1
      packages/boot/plugin-manager/src/tools.ts
  19. 6 0
      packages/boot/plugin-manager/src/types.ts
  20. 59 0
      packages/boot/plugin-manager/tests/build-approval.spec.ts
  21. 68 1
      packages/boot/plugin-manager/tests/manager.spec.ts
  22. 2 0
      packages/boot/plugin-manager/tests/tools.spec.ts
  23. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  24. 2 0
      packages/client/ui-settings-plugin-inventory/README.md
  25. 2 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  26. 6 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  27. 13 2
      packages/client/ui-settings-plugin-inventory/src/client/management.tsx
  28. 18 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  29. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  30. 7 0
      snapshots/session/plugin-manager/tool-schemas.expected.json

+ 2 - 2
.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

+ 2 - 0
.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.

+ 2 - 0
.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 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。

+ 1 - 1
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

+ 1 - 1
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)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 

+ 2 - 2
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

+ 1 - 1
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<ChangeResult>

+ 1 - 1
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<ChangeResult>

+ 2 - 2
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

+ 7 - 0
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."

+ 7 - 0
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."

+ 1 - 1
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'

+ 2 - 2
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

+ 2 - 0
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 |

+ 2 - 0
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,允许脚本以宿主用户权限执行命令,并在再次安装失败后保留。该操作只能批准当前尚未决定的包名,不能覆盖已有拒绝或通配符规则。安装清理保留这些决定,重试沿用原来的启用选项。
+
 ### 配置
 
 | 字段 | 默认值 | 含义 |

+ 44 - 0
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<string[]> {
+  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<void> {
+  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 })
+}

+ 11 - 3
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<ChangeResult> {
     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 ''

+ 5 - 1
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))

+ 6 - 0
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[]
 }

+ 59 - 0
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()
+})

+ 68 - 1
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 () => {

+ 2 - 0
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')
 })

+ 2 - 2
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

+ 2 - 0
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.

+ 2 - 0
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 过滤两组。

+ 6 - 0
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',

+ 13 - 2
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<BundleInfo[]>
   setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
   setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
-  installBundle(spec: string, options: { enabled: boolean }): Promise<ChangeResult>
+  installBundle(spec: string, options: InstallBundleOptions): Promise<ChangeResult>
   removeBundle(name: string): Promise<ChangeResult>
 }
 
@@ -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 <section className={css.manager} aria-label={t('bundles')} aria-busy={state.busy}>
     <h3>{t('bundles')}</h3>
     <form className={css.install} onSubmit={(event) => {
@@ -86,6 +88,15 @@ export function BundleManager({ manager, state, t }: {
       {state.result.remainingDependencies?.length ? <span> {t('remainingDependencies')}: {state.result.remainingDependencies.join(', ')}</span> : null}
       {state.result.warnings?.length ? <span> {t('existingFailures')}: {state.result.warnings.join('\n')}</span> : null}
     </p>}
+    {result !== undefined && pendingBuilds !== undefined && pendingBuilds.length > 0 ? <div role="group" aria-label={t('buildApproval')}>
+      <p>{t('buildApprovalDescription')}</p>
+      <ul>{pendingBuilds.map(name => <li key={name}><code>{name}</code></li>)}</ul>
+      <button type="button" disabled={state.busy} onClick={() => {
+        void state.run(() => manager.installBundle(result.target, {
+          enabled: result.enabled !== false, approvedBuilds: pendingBuilds,
+        }))
+      }}>{t('approveBuildsAndRetry')}</button>
+    </div> : null}
     <ul className={css.bundleList}>{state.bundles.map(bundle => <li key={bundle.name}>
       <span><strong>{bundle.name}</strong> {bundle.version}</span>
       <label><input type="checkbox" role="switch" aria-label={t('bundleSwitch', { name: bundle.name })}

+ 18 - 0
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -440,6 +440,24 @@ function managementFixture() {
   }
 }
 
+it('requires a click to approve the displayed packages and retries the original install options', async () => {
+  const manager = managementFixture()
+  const result = { changed: false, application: 'failed' as const, stage: 'install' as const,
+    target: 'addon@1', enabled: false, pendingBuilds: ['native', '@scope/helper'] }
+  function Form() {
+    const state = usePluginManagement(manager, true, 0)
+    return <BundleManager manager={manager} t={t} state={{ ...state, result: state.result ?? result }} />
+  }
+  render(<Form />)
+  expect(screen.getByText('native')).toBeDefined()
+  expect(screen.getByText('@scope/helper')).toBeDefined()
+  expect(manager.installBundle).not.toHaveBeenCalled()
+  fireEvent.change(screen.getByRole('textbox', { name: en.packageSpec }), { target: { value: 'unrelated' } })
+  fireEvent.click(screen.getByRole('button', { name: en.approveBuildsAndRetry }))
+  await waitFor(() => { expect(manager.installBundle).toHaveBeenCalledWith('addon@1', { enabled: false, approvedBuilds: ['native', '@scope/helper'] }) })
+  await waitFor(() => { expect(screen.queryByRole('group', { name: en.buildApproval })).toBeNull() })
+})
+
 it('ignores inventory completion after unmount and reports current read failures', async () => {
   for (const failure of [undefined, new Error('late failure')]) {
     const read = Promise.withResolvers<Awaited<ReturnType<PluginManagement['listBundles']>>>()

+ 3 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1465,7 +1465,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>',
         description: 'Install a package using the same pnpm implementation as dsh plugin.',
-        parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle; defaults to true.' }],
+        parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Activation defaults to true; explicit build approvals persist for this profile before installation.' }],
         returns: 'Package-manager diagnostics and observed activation outcome.',
       },
       {
@@ -4095,7 +4095,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ChangeResult',
-    declaration: 'export interface ChangeResult {\n    changed: boolean;\n    application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\';\n    stage: \'install\' | \'enable\' | \'remove\';\n    target: string;\n    enabled?: boolean;\n    error?: ManagementError;\n    warnings?: string[];\n    remainingDependencies?: string[];\n    cleanup?: {\n        name: string;\n        packageResult?: PackageResult;\n        error?: ManagementError;\n    };\n    packageResult?: PackageResult;\n}',
+    declaration: 'export interface ChangeResult {\n    changed: boolean;\n    application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\';\n    stage: \'install\' | \'enable\' | \'remove\';\n    target: string;\n    enabled?: boolean;\n    error?: ManagementError;\n    warnings?: string[];\n    remainingDependencies?: string[];\n    cleanup?: {\n        name: string;\n        packageResult?: PackageResult;\n        error?: ManagementError;\n    };\n    packageResult?: PackageResult;\n    pendingBuilds?: string[];\n    approvedBuilds?: string[];\n}',
   },
   {
     name: 'ClientArtifactBaseline',
@@ -4659,7 +4659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'InstallBundleOptions',
-    declaration: 'export interface InstallBundleOptions {\n    enabled?: boolean;\n}',
+    declaration: 'export interface InstallBundleOptions {\n    enabled?: boolean;\n    approvedBuilds?: string[];\n}',
   },
   {
     name: 'InvariantFailure',

+ 7 - 0
snapshots/session/plugin-manager/tool-schemas.expected.json

@@ -286,6 +286,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."