Przeglądaj źródła

Merge pull request #4277 from deepseek-harness/turtle/plugin-build-approval

fix(plugin-manager): approve blocked dependency builds before retrying
Turtle 3 tygodni temu
rodzic
commit
faf8da83f0
38 zmienionych plików z 358 dodań i 199 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  2. 3 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  3. 3 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 1 3
      docs/config-catalog.md
  6. 1 3
      docs/config-catalog.zh.md
  7. 2 2
      docs/module-graph.i18n.yaml
  8. 1 3
      docs/module-graph.md
  9. 1 3
      docs/module-graph.zh.md
  10. 2 2
      docs/subsystems/boot.i18n.yaml
  11. 3 3
      docs/subsystems/boot.md
  12. 3 3
      docs/subsystems/boot.zh.md
  13. 2 2
      docs/tool-catalog.i18n.yaml
  14. 7 0
      docs/tool-catalog.md
  15. 7 0
      docs/tool-catalog.zh.md
  16. 1 1
      packages/api/remotes/src/client/index.ts
  17. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  18. 4 16
      packages/boot/plugin-manager/README.md
  19. 4 16
      packages/boot/plugin-manager/README.zh.md
  20. 0 8
      packages/boot/plugin-manager/package.json
  21. 50 0
      packages/boot/plugin-manager/src/build-approval.ts
  22. 10 0
      packages/boot/plugin-manager/src/failure.ts
  23. 17 70
      packages/boot/plugin-manager/src/index.ts
  24. 5 1
      packages/boot/plugin-manager/src/tools.ts
  25. 7 1
      packages/boot/plugin-manager/src/types.ts
  26. 69 0
      packages/boot/plugin-manager/tests/build-approval.spec.ts
  27. 82 37
      packages/boot/plugin-manager/tests/manager.spec.ts
  28. 2 0
      packages/boot/plugin-manager/tests/tools.spec.ts
  29. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  30. 2 0
      packages/client/ui-settings-plugin-inventory/README.md
  31. 2 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  32. 8 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  33. 13 2
      packages/client/ui-settings-plugin-inventory/src/client/management.tsx
  34. 27 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  35. 4 4
      packages/extensions/tool-cordis/src/api-catalog.ts
  36. 0 9
      pnpm-lock.yaml
  37. 0 0
      snapshots/session/plugin-manager/session.v3.jsonl
  38. 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: eef121bd7968547ebec83bbb52c4776f84082278
+2026-09-14-current-profile-plugin-management.zh.md: 009d1df124bfee31385c25b5c1e80696556448a4

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -16,10 +16,12 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
 
 Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
 
-This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
+This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 
 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. Agent tools may grant approval on the user's behalf; their instructions require explicit conversational consent, while the service validates only pending package names. Approval rejects anchors and aliases inside `allowBuilds` to prevent shared YAML nodes from changing unrequested permissions.
+
 ## 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.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -16,10 +16,12 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
 
-这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 
 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
 
+构建审批在同一个 profile 写锁内更新 pnpm 11 尚未决定的 `allowBuilds` 条目,并保留无关 YAML。授权按准确包名持久化,不采用无条件允许脚本的策略。重试只接受仍在待审批列表中的包名,因此过期请求不能覆盖后续拒绝。包清理保留审批设置,后续重试可以复用授权而不必保留部分安装的依赖。service 报告仅涉及策略的变化。Agent 工具可以代表用户授权;工具说明要求用户在对话中明确同意,而 service 仅验证待审批包名。审批拒绝 `allowBuilds` 内的锚点和别名,避免共享 YAML 节点改变未请求的权限。
+
 ## 考虑过的替代方案
 
 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。

+ 2 - 2
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.zh.md: a9a07b97da99ee5ce6b5666255dd07ea0080f5ae
+config-catalog.md: b2a1177bc58e71fcff318613fb4781ff66b355a7
+config-catalog.zh.md: fd40eb46ed9103a5a10688f1bf75215c4ab084b2

+ 1 - 3
docs/config-catalog.md

@@ -1810,12 +1810,10 @@ Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/s
 Requires: `loader` · `profileContext`
 
 ```ts config-catalog
-/** Limits for package diagnostics and change notifications. */
+/** Limits for package diagnostics and lock acquisition. */
 export interface Config {
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
 }

+ 1 - 3
docs/config-catalog.zh.md

@@ -1812,12 +1812,10 @@ export interface PlanModeConfig {
 依赖: `loader` · `profileContext`
 
 ```ts config-catalog
-/** Limits for package diagnostics and change notifications. */
+/** Limits for package diagnostics and lock acquisition. */
 export interface Config {
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
 }

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 5206791a0e1e9898d124b2d3c154a64a2979f77d
-module-graph.zh.md: eaa5b4fc835904fbc86188f3099cab1ff84ffee1
+module-graph.md: 9c52f02cc990f0be4d317f1740da0a4625ac11a0
+module-graph.zh.md: 7822bc5a576a24c41f33da055f57d948d9c8ed72

+ 1 - 3
docs/module-graph.md

@@ -1085,10 +1085,8 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
-  pkg_plugin_manager --> pkg_agent
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
-  pkg_plugin_manager --> pkg_llm
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
@@ -1545,7 +1543,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 1 - 3
docs/module-graph.zh.md

@@ -1087,10 +1087,8 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
-  pkg_plugin_manager --> pkg_agent
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
-  pkg_plugin_manager --> pkg_llm
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
@@ -1547,7 +1545,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 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: 18f06789f450b0694329feb1fb8b7a2df7a9905a
+boot.zh.md: afe4c6f79c3cbe2d8ef8570e512fa4669c10b1ff

+ 3 - 3
docs/subsystems/boot.md

@@ -12,9 +12,9 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
 
 `BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
 
-`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer.
+`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation.
 
-`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path.
+`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -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>

+ 3 - 3
docs/subsystems/boot.zh.md

@@ -12,9 +12,9 @@
 
 `BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
 
-`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。
+`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。
 
-`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden` 或 `failed`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记与完整诊断日志路径。
+`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -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: 0166c7dd9829f0774c277508299726aac8426969
+tool-catalog.zh.md: 7722301c04febbd7b8d1c5509bfdb9e3881d0b76

+ 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: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission 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: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission 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: cce53d2d0f981128f8749e70c1442abc1ee1cc8c
+README.zh.md: f42a329342ff8dbd7404f236802a9a948d9fc45b

+ 4 - 16
packages/boot/plugin-manager/README.md

@@ -37,13 +37,14 @@ 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 all pending package names in the profile, including earlier attempts. Web offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. 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. Approval rejects YAML anchors or aliases inside `allowBuilds`. Installation cleanup retains these decisions. Retry preserves the original activation choice.
+
 ### Configuration
 
 | Field | Default | Meaning |
 |---|---|---|
 | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
 | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. |
-| `notificationDelayMs` | `250` | Delay in milliseconds for combining operation notices. |
 
 -----
 
@@ -75,7 +76,7 @@ Results contain the last attempted stage, target, saved-state change, applicatio
 
 #### What the model sees
 
-The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics.
+The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. Management operations do not inject messages into Agents.
 
 #### Token effect
 
@@ -85,24 +86,11 @@ The tool declaration is present when its consumer is mounted; each invocation ad
 
 Tool results append to the transcript. Enabling or disabling other tools can change subsequent tool declarations and their cache reuse.
 
-### Configuration change notices
-
-#### What the model sees
-
-Consecutive operation results are combined within `notificationDelayMs` and injected into each affected live Agent. Notices include the application outcome, disclose omitted results when the configured output bound is reached, and do not wake an idle Agent.
-
-#### Token effect
-
-Notices add conditional user-message context to each affected Agent.
-
-#### KV Cache effect
-
-Notices append context; they do not rewrite earlier messages.
-
 ## Known Limitations and Deferred Work
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- Web approves the entire displayed pending group; it has no per-package selection.
 - Package replacements require restarting the process to load a fresh JavaScript module generation.
 - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
 - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.

+ 4 - 16
packages/boot/plugin-manager/README.zh.md

@@ -37,13 +37,14 @@ kind: "package-reference"
 
 插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
 
+pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审批包名,包括此前安装留下的条目。Web 提供“允许这些脚本并重试”;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代表用户授权。service 验证包名仍在待审批列表中,不验证对话审批。授权按包名保存在当前 profile,允许脚本以宿主用户权限执行命令,并在再次安装失败后保留。该操作只能批准当前尚未决定的包名,不能覆盖已有拒绝或通配符规则。审批拒绝 `allowBuilds` 内的 YAML 锚点或别名。安装清理保留这些决定,重试沿用原来的启用选项。
+
 ### 配置
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
 | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
 | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
-| `notificationDelayMs` | `250` | 合并操作通知的延迟毫秒数。 |
 
 -----
 
@@ -75,7 +76,7 @@ kind: "package-reference"
 
 #### 模型看到什么
 
-[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。
+[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。管理操作不会向 Agent 注入消息。
 
 #### Token 影响
 
@@ -85,24 +86,11 @@ kind: "package-reference"
 
 工具结果追加到对话中。启停其他工具可能改变后续工具声明及其缓存复用。
 
-### 配置变更通知
-
-#### 模型看到什么
-
-连续操作结果在 `notificationDelayMs` 内合并后注入每个受影响的存活 Agent。通知包含应用结果,达到配置的输出上限时标明省略的结果数,不会唤醒空闲 Agent。
-
-#### Token 影响
-
-通知按需向每个受影响 Agent 追加用户消息上下文。
-
-#### KV Cache 影响
-
-通知追加上下文,不改写先前消息。
-
 ## 已知限制与延期工作
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- Web 一次批准整个待审批列表,暂不支持逐包选择。
 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。

+ 0 - 8
packages/boot/plugin-manager/package.json

@@ -63,18 +63,13 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^"
   },
   "peerDependenciesMeta": {
-    "@deepseek-ai/dsh-agent": {
-      "optional": true
-    },
     "@deepseek-ai/dsh-hmr": {
       "optional": true
     }
@@ -82,14 +77,11 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^"
   }

+ 50 - 0
packages/boot/plugin-manager/src/build-approval.ts

@@ -0,0 +1,50 @@
+/** 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 { isAlias, isMap, isNode, isScalar, parseDocument, visit } from 'yaml'
+import { ManagementFailure } from './failure.ts'
+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')
+  visit(builds ?? null, (_key, node) => {
+    if (isAlias(node) || (isNode(node) && 'anchor' in node && node.anchor)) {
+      throw new Error('allowBuilds must not contain YAML anchors or aliases')
+    }
+  })
+  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 a name is no longer pending or allowBuilds contains YAML anchors or aliases; 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 ManagementFailure('stale-approval')
+  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 })
+}

+ 10 - 0
packages/boot/plugin-manager/src/failure.ts

@@ -0,0 +1,10 @@
+/** Localizable rejections shared by profile management operations. */
+import type { ManagementError } from './types.ts'
+
+/** Expected management rejection; presentation belongs to the caller's locale. */
+export class ManagementFailure extends Error {
+  /** Code rendered by the caller's locale dictionary. */
+  readonly code: ManagementError['code']
+  /** @param code Localizable management rejection. */
+  constructor(code: ManagementError['code']) { super(code); this.code = code }
+}

+ 17 - 70
packages/boot/plugin-manager/src/index.ts

@@ -10,19 +10,17 @@ import { readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
 import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-hmr'
 import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
-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 { ManagementFailure } from './failure.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'
 
-/** Limits for package diagnostics and change notifications. */
+/** Limits for package diagnostics and lock acquisition. */
 export interface Config {
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
 }
@@ -50,12 +48,6 @@ function managementError(error: unknown): ManagementError {
     : { code: 'operation-error', diagnostic: error instanceof Error ? error.message : String(error) }
 }
 
-/** Expected management rejection; presentation belongs to the caller's locale. */
-class ManagementFailure extends Error {
-  readonly code: ManagementError['code']
-  constructor(code: ManagementError['code']) { super(code); this.code = code }
-}
-
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Persistent management of the current profile's composition and packages. */
@@ -68,21 +60,14 @@ export class PluginManager extends TypertRemoteService {
   static inject = ['loader', 'profileContext']
   static Config: z<Config> = z.object({
     outputBytes: z.number().step(1).min(1).default(16384),
-    notificationDelayMs: z.number().step(1).min(0).default(250),
     lockWaitMs: z.number().step(1).min(0).default(120000),
   })
   private readonly ownerEntryId: string | undefined
   private readonly packageOperations = new Set<Promise<PackageResult>>()
   private readonly profile: ProfileContext
   private readonly outputBytes: number
-  private readonly notificationDelayMs: number
   private readonly lockWaitMs: number
   private readonly ownerContext: Context
-  private pendingNotice = ''
-  private omittedNotices = 0
-  private noticeTimer: ReturnType<typeof setTimeout> | undefined
-  private noticeDelivered: PromiseWithResolvers<void> | undefined
-  private readonly noticeAgents = new Set<Agent>()
   private readonly abort = new AbortController()
 
   constructor(ctx: Context, config: Config) {
@@ -91,13 +76,10 @@ export class PluginManager extends TypertRemoteService {
     this.ownerContext = ctx
     this.profile = ctx.profileContext
     this.outputBytes = (config as Required<Config>).outputBytes
-    this.notificationDelayMs = (config as Required<Config>).notificationDelayMs
     this.lockWaitMs = (config as Required<Config>).lockWaitMs
     ctx.effect(() => async () => {
       this.abort.abort()
       await Promise.allSettled([...this.packageOperations])
-      clearTimeout(this.noticeTimer)
-      this.flushNotice()
     }, 'plugin-manager: package cancellation')
   }
 
@@ -190,18 +172,28 @@ 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) {
+          try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
+          catch (error) {
+            this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
+          }
+          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.
@@ -329,8 +321,7 @@ export class PluginManager extends TypertRemoteService {
     operation: (result: ChangeResult) => Promise<ChangeResult['application'] | void>,
     request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
   ): Promise<ChangeResult> {
-    let notice: Promise<void> | undefined
-    const locked = () => withFileLock(join(this.profile.dir, 'package.json'), async () => {
+    return withFileLock(join(this.profile.dir, 'package.json'), async () => {
       this.abort.signal.throwIfAborted()
       const before = this.diskState()
       const result: ChangeResult = { ...request, changed: false,
@@ -342,16 +333,12 @@ export class PluginManager extends TypertRemoteService {
         result.error = managementError(error)
       }
       result.changed = before !== this.diskState()
-      notice = this.notify(result)
       return result
     }, { waitMs: this.lockWaitMs })
-    const result = await locked()
-    await notice
-    return result
   }
 
   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 ''
@@ -359,46 +346,6 @@ export class PluginManager extends TypertRemoteService {
       }
     }).join('\u0000')
   }
-
-  private notify(result: ChangeResult): Promise<void> {
-    const agents = this.ownerContext.get('agents')?.list() ?? []
-    if (agents.length === 0) return Promise.resolve()
-    for (const agent of agents) this.noticeAgents.add(agent)
-    this.noticeDelivered ??= Promise.withResolvers<void>()
-    const delivered = this.noticeDelivered.promise
-    const notice = JSON.stringify({ profile: this.profile.name, ...result })
-    if (Buffer.byteLength(this.pendingNotice + notice) > this.outputBytes) {
-      this.omittedNotices += 1
-    } else {
-      this.pendingNotice += `${notice}\n`
-    }
-    if (this.noticeTimer !== undefined) return delivered
-    this.noticeTimer = setTimeout(() => { this.flushNotice() }, this.notificationDelayMs)
-    return delivered
-  }
-
-  private flushNotice(): void {
-    this.noticeTimer = undefined
-    const text = this.pendingNotice + (this.omittedNotices === 0 ? '' : JSON.stringify({ omitted: this.omittedNotices, refresh: 'plugin_manager' }) + '\n')
-    this.pendingNotice = ''
-    this.omittedNotices = 0
-    const delivered = this.noticeDelivered
-    this.noticeDelivered = undefined
-    if (delivered === undefined) return
-    const agents = [...this.noticeAgents]
-    this.noticeAgents.clear()
-    for (const agent of agents) {
-      try {
-        agent.inject(createUserMessage({
-          content: [{ type: 'text', text }],
-          source: { kind: 'plugin', plugin: 'plugin-manager' },
-        }))
-      } catch (error) {
-        this.ownerContext.logger.warn('Plugin management notification could not reach an Agent', error)
-      }
-    }
-    delivered.resolve()
-  }
 }
 
 export default PluginManager

+ 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: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission 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))

+ 7 - 1
packages/boot/plugin-manager/src/types.ts

@@ -7,7 +7,7 @@ export type ReadOnlyReason = 'management-required' | 'unaddressable'
 
 /** Localizable management failure and optional external diagnostic. */
 export interface ManagementError {
-  code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'operation-error'
+  code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'operation-error'
   diagnostic?: string
 }
 
@@ -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[]
 }

+ 69 - 0
packages/boot/plugin-manager/tests/build-approval.spec.ts

@@ -0,0 +1,69 @@
+/** 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\nother: &unrelated value\ncopy: *unrelated\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('stale-approval')
+  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()
+})
+
+it.each([
+  'allowBuilds:\n  native: &pending set this to true or false\n  other: *pending\n',
+  'allowBuilds: &builds\n  native: set this to true or false\nshared: *builds\n',
+  'shared: &pending set this to true or false\nallowBuilds:\n  native: *pending\n',
+])('rejects shared YAML approval nodes without changing permissions: %s', async (original) => {
+  const { dir, filename } = fixture(original)
+  await expect(approveBuilds(dir, ['native'])).rejects.toThrow()
+  expect(readFileSync(filename, 'utf8')).toBe(original)
+})

+ 82 - 37
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -1,11 +1,9 @@
 /** 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 { realpath } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import type { Context } from '@deepseek-ai/cordis'
-import type AgentRegistry from '@deepseek-ai/dsh-agent'
-import { Session, SessionId } from '@deepseek-ai/dsh-session'
-import type { UserMessage } from '@deepseek-ai/dsh-llm'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import {
   boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches,
@@ -17,9 +15,11 @@ 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, parseDocument } from 'yaml'
 
 async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
-  const home = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
+  // pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
+  const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
   const dir = join(home, 'profiles', 'test')
   const anchor = join(home, 'package.json')
   writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
@@ -139,6 +139,75 @@ 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, error: { code: 'stale-approval' } })
+  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')
+  const policy = parseDocument(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))
+  policy.set('offline', true)
+  policy.set('storeDir', join(profile.cwd, 'store'))
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
+  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 () => {
@@ -188,29 +257,6 @@ it('keeps saved changes after activation failure and allows a corrected configur
 })
 
 
-it('combines concurrent changes into durable notices without waking Agents', async () => {
-  const session = Session.create(SessionId('manager-notices'))
-  const wake = vi.fn()
-  const notices: UserMessage[] = []
-  const liveAgents = [{
-    inject(message: UserMessage) {
-      notices.push(message)
-      session.append('user/message', message, { surfaceOp: 'append' })
-    }, followup: wake, steer: wake,
-  }, { inject() { throw new Error('already disposed') } }]
-  const agents = { list: () => liveAgents }
-  const { manager } = await fixture('live', false, (ctx) => { ctx.provide('agents', agents as unknown as AgentRegistry) })
-  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
-  await Promise.all([manager.setPluginEnabled(id, false), manager.setPluginEnabled(id, true)])
-  expect(notices).toHaveLength(1)
-  const noticeText = notices[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
-  expect(noticeText).toContain('"enabled":false')
-  expect(noticeText).toContain('"enabled":true')
-  expect(wake).not.toHaveBeenCalled()
-  expect(session.snapshotEvents().filter(row => row.type === 'user/message')).toHaveLength(1)
-})
-
-
 it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
   const { manager, dir, profile } = await fixture()
   writeFileSync(profile.installAnchor, '{}')
@@ -396,15 +442,6 @@ it('handles missing patch files and retains non-Error package diagnostics', asyn
   await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
 })
 
-it('bounds batched notices and discloses omitted operation results', async () => {
-  const messages: UserMessage[] = []
-  const { manager } = await fixture('live', false, (ctx) => {
-    ctx.provide('agents', { list: () => [{ inject: (message: UserMessage) => { messages.push(message) } }] } as unknown as AgentRegistry)
-  }, { outputBytes: 1, notificationDelayMs: 0 })
-  await manager.setBundleEnabled('extra', false)
-  expect(messages[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')).toContain('"omitted":1')
-})
-
 it('applies a manager change through the active HMR service', async () => {
   const { manager } = await fixture()
   const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
@@ -513,3 +550,11 @@ it('applies watched configuration while pnpm installation is still running', asy
   expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
   expect(ctx.get('managedProbe')).toBeUndefined()
 })
+
+it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
+  const { manager, dir } = await fixture()
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
+  onTestFinished(() => { run.mockRestore() })
+  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
+})

+ 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: dd8c778341feb3969186f1d916362516fa3dccf4
+README.zh.md: e16760937ff003c7cfff95129846a8249849f2c6

+ 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. One click approves the entire displayed group, which can include pending packages from earlier attempts. 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 过滤两组。

+ 8 - 0
packages/client/ui-settings-plugin-inventory/src/client/locales.ts

@@ -10,11 +10,15 @@ export const zh = {
   'not-removable': '该依赖不属于当前 profile,或是管理所需的组件。',
   'bundle-in-use': '其他配置仍在使用该组合包,请先停用相应插件。',
   'stop-profile': '请停止当前 profile,再通过 dsh plugin 删除仍在使用的依赖。',
+  'stale-approval': '构建审批已变化,请重新安装以刷新待审批的包。',
   'operation-error': '请查看诊断信息。',
   installFailed: '安装失败', enableFailed: '启停失败', removeFailed: '删除失败',
   cleanup: '清理新增依赖', remainingDependencies: '剩余新增依赖', existingFailures: '其他诊断',
   bundles: '组合包', packageSpec: 'npm 包名称或本地路径', install: '安装', remove: '删除',
   enableAfterInstall: '安装后启用', applying: '正在应用更改…',
+  buildApproval: '构建脚本审批',
+  buildApprovalDescription: '以下依赖的安装脚本尚未获准运行。允许后,脚本可使用你的系统权限执行命令,授权保存在当前 profile 中。确认信任这些依赖后再重试。',
+  approveBuildsAndRetry: '允许这些脚本并重试',
   operationFailed: '操作失败', applied: '已生效', 'restart-required': '重启后生效', overridden: '被其他配置覆盖',
   pluginSwitch: '启停插件 {name}', bundleSwitch: '启停组合包 {name}',
   tab: '插件列表',
@@ -71,11 +75,15 @@ export const en = {
   'not-removable': 'This dependency is not owned by the current profile or is required for management.',
   'bundle-in-use': 'Other configuration still uses this bundle. Disable those plugins first.',
   'stop-profile': 'Stop this profile, then remove the dependency still in use with dsh plugin.',
+  'stale-approval': 'Build approvals changed. Retry installation to refresh the pending packages.',
   'operation-error': 'See the diagnostics for details.',
   installFailed: 'Installation failed', enableFailed: 'Enablement failed', removeFailed: 'Removal failed',
   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 })}

+ 27 - 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']>>>()
@@ -552,3 +570,12 @@ it('shows current-page sync errors and retries without re-reading Host inventory
   act(() => { sync.set({ syncing: false, failures: [] }) })
   expect(screen.queryByRole('alert')).toBeNull()
 })
+
+it.each([en, zh])('renders stale build approval in the selected locale', (dictionary) => {
+  const localized = ((key: PluginInventoryLocaleKey) => dictionary[key]) as PluginInventorySettingsTabProps['t']
+  render(<BundleManager manager={managementFixture()} t={localized} state={{
+    plugins: [], bundles: [], busy: false, refresh: 0, error: undefined, run: async () => {},
+    result: { stage: 'install', target: 'addon', changed: false, application: 'failed', error: { code: 'stale-approval' } },
+  }} />)
+  expect(screen.getByRole('alert').textContent).toContain(dictionary['stale-approval'])
+})

+ 4 - 4
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',
@@ -4871,7 +4871,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ManagementError',
-    declaration: 'export interface ManagementError {\n    code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'operation-error\';\n    diagnostic?: string;\n}',
+    declaration: 'export interface ManagementError {\n    code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'operation-error\';\n    diagnostic?: string;\n}',
   },
   {
     name: 'ManualCompactAgentContext',

+ 0 - 9
pnpm-lock.yaml

@@ -1521,21 +1521,12 @@ importers:
       '@deepseek-ai/cordis-plugin-timer':
         specifier: workspace:^
         version: link:../../../vendor/timer
-      '@deepseek-ai/dsh-agent':
-        specifier: workspace:^
-        version: link:../../core/agent
       '@deepseek-ai/dsh-hmr':
         specifier: workspace:^
         version: link:../hmr
       '@deepseek-ai/dsh-host-plugin-inventory':
         specifier: workspace:^
         version: link:../../host/plugin-inventory
-      '@deepseek-ai/dsh-llm':
-        specifier: workspace:^
-        version: link:../../llm/llm
-      '@deepseek-ai/dsh-session':
-        specifier: workspace:^
-        version: link:../../core/session
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess

Plik diff jest za duży
+ 0 - 0
snapshots/session/plugin-manager/session.v3.jsonl


+ 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: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.",
+            "items": {
+              "type": "string"
+            }
+          },
           "offset": {
             "type": "number",
             "description": "Zero-based list offset; defaults to 0."

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików