pi-web-ui 0.86.2 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -40,7 +40,9 @@ export class McpClient {
40
40
  starting = null;
41
41
  /** 已启动次数(含自愈重启;诊断/测试用)。 */
42
42
  startedCount = 0;
43
- constructor(name, spec, log) {
43
+ constructor(name,
44
+ /** 启动规格;热加载按它判断「这个服务器要不要重启」(见 McpBridge.reload)。 */
45
+ spec, log) {
44
46
  this.spec = spec;
45
47
  this.name = name;
46
48
  this.log = log ?? (() => { });
@@ -290,27 +292,65 @@ export class McpClient {
290
292
  this.pending.clear();
291
293
  }
292
294
  }
295
+ /**
296
+ * 解析 <dataDir>/mcp.json 的文本 → 规范化服务器清单。
297
+ * `null` = 不是合法 JSON 对象(**与「没有服务器」区分开**:热加载遇到坏配置要保留在跑的
298
+ * 服务器,而 `{ "servers": {} }` 或删掉文件是「确实没有服务器」的明确意图)。
299
+ */
300
+ export function parseMcpConfig(text) {
301
+ let parsed;
302
+ try {
303
+ parsed = JSON.parse(text);
304
+ }
305
+ catch {
306
+ return null;
307
+ }
308
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
309
+ return null;
310
+ const servers = {};
311
+ for (const [name, s] of Object.entries(parsed.servers ?? {})) {
312
+ if (!s || typeof s.command !== "string" || !s.command.trim())
313
+ continue;
314
+ servers[name] = {
315
+ command: s.command,
316
+ args: Array.isArray(s.args) ? s.args.map(String) : [],
317
+ cwd: typeof s.cwd === "string" ? s.cwd : undefined,
318
+ env: s.env && typeof s.env === "object" ? s.env : undefined,
319
+ protocolVersion: typeof s.protocolVersion === "string" ? s.protocolVersion : undefined,
320
+ };
321
+ }
322
+ return { servers };
323
+ }
293
324
  /** 读取 <dataDir>/mcp.json 里的服务器清单(尽力而为)。 */
294
325
  export function readMcpConfig(dataDir) {
295
326
  try {
296
- const raw = JSON.parse(readFileSync(join(dataDir, "mcp.json"), "utf8"));
297
- const servers = {};
298
- for (const [name, s] of Object.entries(raw.servers ?? {})) {
299
- if (!s || typeof s.command !== "string" || !s.command.trim())
300
- continue;
301
- servers[name] = {
302
- command: s.command,
303
- args: Array.isArray(s.args) ? s.args.map(String) : [],
304
- cwd: typeof s.cwd === "string" ? s.cwd : undefined,
305
- env: s.env && typeof s.env === "object" ? s.env : undefined,
306
- };
307
- }
308
- return { servers };
327
+ return parseMcpConfig(readFileSync(join(dataDir, "mcp.json"), "utf8")) ?? { servers: {} };
309
328
  }
310
329
  catch {
311
330
  return { servers: {} };
312
331
  }
313
332
  }
333
+ /**
334
+ * 服务器的规范化快照:只保留影响行为的字段,env 键序无关。
335
+ * 热加载的「配置变了吗」与「这个服务器要不要重启」都按它比较 —— 改缩进、重排键名、加尾随
336
+ * 换行都不算变更,不该重启任何子进程。
337
+ */
338
+ export function mcpServerSnapshot(spec) {
339
+ const env = {};
340
+ for (const k of Object.keys(spec.env ?? {}).sort())
341
+ env[k] = spec.env[k];
342
+ return {
343
+ command: spec.command,
344
+ args: spec.args ?? [],
345
+ cwd: spec.cwd ?? null,
346
+ env,
347
+ protocolVersion: spec.protocolVersion ?? null,
348
+ };
349
+ }
350
+ /** 两个规格是否等价(等价 = 该服务器的子进程不必重启)。 */
351
+ function sameMcpSpec(a, b) {
352
+ return JSON.stringify(mcpServerSnapshot(a)) === JSON.stringify(mcpServerSnapshot(b));
353
+ }
314
354
  /**
315
355
  * 整个 MCP 管理器的工具适配:把每个 MCP 工具变成 PluginAgentTool。
316
356
  * getAllToolsTool(name, callFn) 生成 execute → 转发到对应 McpClient.call。
@@ -349,17 +389,81 @@ export class McpBridge {
349
389
  async load() {
350
390
  const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
351
391
  await Promise.all(Object.entries(cfg.servers).map(async ([name, spec]) => {
352
- try {
353
- const client = new McpClient(name, spec, this.log);
354
- await client.start();
355
- this.clients.push(client);
356
- for (const t of client.getTools())
357
- this.tools.push(adaptMcpTool(name, t, client));
392
+ const client = await this.startOne(name, spec);
393
+ if (!client)
394
+ return;
395
+ this.clients.push(client);
396
+ for (const t of client.getTools())
397
+ this.tools.push(adaptMcpTool(name, t, client));
398
+ }));
399
+ }
400
+ /** 启动单个服务器:失败只记日志并返回 null(load / reload 共用)。 */
401
+ async startOne(name, spec) {
402
+ try {
403
+ const client = new McpClient(name, spec, this.log);
404
+ await client.start();
405
+ return client;
406
+ }
407
+ catch (err) {
408
+ this.log(`[mcp] 服务器「${name}」启动失败:`, err instanceof Error ? err.message : err);
409
+ return null;
410
+ }
411
+ }
412
+ /**
413
+ * 按磁盘上的最新配置**整体换入**服务器集合(`mcp.json` 热加载用),可重复调用。
414
+ * 三步的顺序都有讲究:
415
+ * 1. 规格没变的服务器**沿用原实例** —— 改一个服务器不该连带重启其它服务器(子进程、
416
+ * 浏览器会话、在途调用全都不动);
417
+ * 2. 新增/变更的**先启动成功才换入**,失败则沿用旧实例 —— 配置写坏不等于把还能用的
418
+ * 工具一起下线;
419
+ * 3. 最后才 close 掉被移除/被替换的旧实例,并按新集合重建工具表。
420
+ */
421
+ async reload() {
422
+ const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
423
+ const next = new Map();
424
+ let kept = 0;
425
+ for (const client of this.clients) {
426
+ const spec = cfg.servers[client.name];
427
+ if (!spec || !sameMcpSpec(spec, client.spec))
428
+ continue;
429
+ next.set(client.name, client);
430
+ kept++;
431
+ }
432
+ let started = 0;
433
+ let failed = 0;
434
+ await Promise.all(Object.entries(cfg.servers)
435
+ .filter(([name]) => !next.has(name))
436
+ .map(async ([name, spec]) => {
437
+ const previous = this.clients.find((c) => c.name === name);
438
+ const fresh = await this.startOne(name, spec);
439
+ if (fresh) {
440
+ next.set(name, fresh);
441
+ started++;
442
+ return;
358
443
  }
359
- catch (err) {
360
- this.log(`[mcp] 服务器「${name}」启动失败:`, err instanceof Error ? err.message : err);
444
+ failed++;
445
+ // 新规格没起来:留住旧实例,别把还能用的服务器一起下线。
446
+ if (previous) {
447
+ next.set(name, previous);
448
+ kept++;
361
449
  }
362
450
  }));
451
+ const stale = this.clients.filter((c) => next.get(c.name) !== c);
452
+ this.clients = [...next.values()];
453
+ this.tools = [];
454
+ for (const c of this.clients)
455
+ for (const t of c.getTools())
456
+ this.tools.push(adaptMcpTool(c.name, t, c));
457
+ for (const c of stale)
458
+ c.close();
459
+ return {
460
+ kept,
461
+ started,
462
+ stopped: stale.length,
463
+ failed,
464
+ servers: this.clients.length,
465
+ tools: this.tools.length,
466
+ };
363
467
  }
364
468
  getTools() {
365
469
  return this.tools;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `mcp.json` 热加载 —— 改完文件即生效,不必再重启 pi-web-ui。
3
+ *
4
+ * 为什么是「盯文件」而不是加协议消息 / 设置面板按钮:`mcp.json` 是用文本编辑器手改的
5
+ * 外部文件(README 一直写着「改完要重启」),没有任何 UI 参与保存;监视文件是唯一不需要
6
+ * 新增协议字段与多语言文案的形态(对照 `reload_models_config`:它有设置面板按钮,所以走协议)。
7
+ *
8
+ * 三条不变量(都有回归用例):
9
+ * 1. **内容没变就不动任何子进程**:指纹按「规范化后的服务器集合」算(服务器顺序无关、
10
+ * 只看影响行为的字段)—— 编辑器保存、重排键、改缩进都不触发重启;
11
+ * 2. **配置写坏不停掉在跑的服务器**:JSON 解析失败只记日志 + 提示一次,绝不碰现有实例
12
+ * (保存过程中的半写状态很常见);
13
+ * 3. **删掉文件 = 清空配置**:与「坏配置」区分开 —— 用户删掉 `mcp.json` 是有意关掉全部
14
+ * MCP 服务器,照常应用。
15
+ */
16
+ import { readFileSync, watch } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { mcpServerSnapshot, parseMcpConfig } from "./mcp-bridge.js";
19
+ /** 规范化指纹:只含影响行为的字段、服务器顺序无关 —— 「内容变了吗」的判据。 */
20
+ function fingerprint(servers) {
21
+ const names = Object.keys(servers).sort();
22
+ return JSON.stringify(names.map((n) => [n, mcpServerSnapshot(servers[n])]));
23
+ }
24
+ export function createMcpHotReload(deps) {
25
+ const file = join(deps.dataDir, "mcp.json");
26
+ const log = deps.log ?? (() => { });
27
+ const debounceMs = deps.debounceMs ?? 300;
28
+ const pollIntervalMs = deps.pollIntervalMs ?? 2000;
29
+ /** 上次应用到运行时的内容指纹;null = 还没播种。 */
30
+ let applied = null;
31
+ let debounce = null;
32
+ let poller = null;
33
+ let watcher = null;
34
+ /** 读一次磁盘:指纹 + 清单(`servers` 为 null = 坏配置)。文件不在按「没有服务器」算。 */
35
+ function readOnce() {
36
+ let raw;
37
+ try {
38
+ raw = readFileSync(file, "utf8");
39
+ }
40
+ catch {
41
+ // 读不到(文件/目录不在)→ 空配置:删掉 mcp.json 就是「关掉全部 MCP 服务器」。
42
+ return { fp: "empty", servers: {} };
43
+ }
44
+ const parsed = parseMcpConfig(raw);
45
+ if (!parsed)
46
+ return { fp: `invalid:${raw}`, servers: null };
47
+ return { fp: `ok:${fingerprint(parsed.servers)}`, servers: parsed.servers };
48
+ }
49
+ async function apply() {
50
+ const { fp, servers } = readOnce();
51
+ if (fp === applied)
52
+ return "unchanged";
53
+ // 先记账再动手:同一个坏文件不反复刷屏,配置没再变也不重试。
54
+ applied = fp;
55
+ if (!servers) {
56
+ log("[mcp] mcp.json 解析失败,保留在跑的 MCP 服务器");
57
+ deps.onNotice?.("warning", "mcp.json 解析失败,已保留当前 MCP 服务器(改好保存后会自动重载)", "Failed to parse mcp.json — keeping the running MCP servers (saving a valid file reloads automatically)");
58
+ return "invalid";
59
+ }
60
+ const summary = await deps.reload();
61
+ deps.onToolsChanged?.();
62
+ log(`[mcp] 配置已热加载:${summary.servers} 个服务器 / ${summary.tools} 个工具` +
63
+ `(沿用 ${summary.kept}、启动 ${summary.started}、关闭 ${summary.stopped}、失败 ${summary.failed})`);
64
+ deps.onNotice?.("info", `mcp.json 已热加载:${summary.servers} 个服务器 / ${summary.tools} 个工具`, `mcp.json reloaded: ${summary.servers} server(s) / ${summary.tools} tool(s)`);
65
+ return "reloaded";
66
+ }
67
+ function run() {
68
+ void apply().catch((err) => log("[mcp] 热加载失败:", err instanceof Error ? err.message : err));
69
+ }
70
+ function schedule() {
71
+ if (debounce)
72
+ return;
73
+ debounce = setTimeout(() => {
74
+ debounce = null;
75
+ run();
76
+ }, debounceMs);
77
+ }
78
+ /** fs.watch 用不了(目录还不存在、网络盘、容器)→ 回落到轮询:单文件不值得上更重的机制。 */
79
+ function fallBackToPolling() {
80
+ watcher?.close();
81
+ watcher = null;
82
+ if (poller)
83
+ return;
84
+ log(`[mcp] 目录监视不可用,mcp.json 热加载回落到 ${pollIntervalMs}ms 轮询`);
85
+ poller = setInterval(run, pollIntervalMs);
86
+ poller.unref();
87
+ }
88
+ function start() {
89
+ if (watcher || poller)
90
+ return;
91
+ // 播种:启动时 load() 已按同一份文件启动过服务器,别在第一个事件里白重载一次。
92
+ applied = readOnce().fp;
93
+ try {
94
+ watcher = watch(deps.dataDir, { persistent: false }, (_event, filename) => {
95
+ // 有些平台/编辑器(保存 = 临时文件 + rename)给不出文件名,拿不到就当命中。
96
+ if (typeof filename === "string" && filename && filename !== "mcp.json")
97
+ return;
98
+ schedule();
99
+ });
100
+ watcher.on("error", () => fallBackToPolling());
101
+ }
102
+ catch {
103
+ fallBackToPolling();
104
+ }
105
+ }
106
+ function dispose() {
107
+ if (debounce)
108
+ clearTimeout(debounce);
109
+ debounce = null;
110
+ if (poller)
111
+ clearInterval(poller);
112
+ poller = null;
113
+ watcher?.close();
114
+ watcher = null;
115
+ }
116
+ return { apply, start, dispose };
117
+ }
@@ -14,6 +14,7 @@
14
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { pick } from "./i18n.js";
17
+ import { ProviderOAuthFlowManager } from "./provider-oauth-flow.js";
17
18
  /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
18
19
  function stripJsonComments(src) {
19
20
  let out = "";
@@ -177,8 +178,68 @@ function parseGoogleModel(m) {
177
178
  }
178
179
  export class ModelAdminService {
179
180
  host;
181
+ oauthFlows;
180
182
  constructor(host) {
181
183
  this.host = host;
184
+ this.oauthFlows = new ProviderOAuthFlowManager({
185
+ modelRuntime: host.modelRuntime,
186
+ emit: host.emit,
187
+ isDisposed: host.isDisposed,
188
+ onLoginSuccess: (provider) => this.onOAuthLoginSuccess(provider),
189
+ });
190
+ }
191
+ startProviderOAuth(provider) {
192
+ return this.oauthFlows.start(provider);
193
+ }
194
+ replyProviderOAuth(flowId, promptId, value) {
195
+ this.oauthFlows.reply(flowId, promptId, value);
196
+ }
197
+ cancelProviderOAuth(flowId) {
198
+ this.oauthFlows.cancel(flowId);
199
+ }
200
+ listProviderOAuthFlows() {
201
+ this.oauthFlows.list();
202
+ }
203
+ dispose() {
204
+ this.oauthFlows.dispose();
205
+ }
206
+ async logoutProviderOAuth(provider) {
207
+ const providerId = provider.trim();
208
+ try {
209
+ const runtime = this.host.modelRuntime();
210
+ if (!providerId || !runtime.getProvider(providerId)?.auth.oauth) {
211
+ throw new Error("该服务商不支持 OAuth 登录");
212
+ }
213
+ await runtime.logout(providerId);
214
+ this.host.invalidatePiConfig();
215
+ await this.host.pushModels();
216
+ await this.listProviders();
217
+ this.host.emit({ type: "provider_oauth_logout_result", provider: providerId, ok: true });
218
+ }
219
+ catch (error) {
220
+ this.host.emit({
221
+ type: "provider_oauth_logout_result",
222
+ provider: providerId,
223
+ ok: false,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ });
226
+ }
227
+ finally {
228
+ this.host.flushSnapshot();
229
+ }
230
+ }
231
+ async onOAuthLoginSuccess(provider) {
232
+ const keys = this.readProviderKeys();
233
+ if (keys[provider]) {
234
+ keys[provider].activeKeyName = null;
235
+ this.writeProviderKeys(keys);
236
+ }
237
+ this.host.onOAuthActivated?.(provider);
238
+ this.host.invalidatePiConfig();
239
+ await this.host.pushModels();
240
+ await this.listProviders();
241
+ this.listProviderKeys();
242
+ this.host.flushSnapshot();
182
243
  }
183
244
  // ---------------------------------------------------------------------------
184
245
  // Built-in provider multiple key store (one provider, several API keys).
@@ -200,7 +261,11 @@ export class ModelAdminService {
200
261
  const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
201
262
  if (!pid || keys.length === 0)
202
263
  continue;
203
- const activeKeyName = entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName) ? entry.activeKeyName : keys[0].name;
264
+ const activeKeyName = entry.activeKeyName === null
265
+ ? null
266
+ : entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
267
+ ? entry.activeKeyName
268
+ : keys[0].name;
204
269
  out[pid] = { activeKeyName, keys };
205
270
  }
206
271
  return out;
@@ -606,6 +671,16 @@ export class ModelAdminService {
606
671
  this.host.emit({ type: "notice", level: "error", text: "请填写服务商 ID", textEn: "Enter a provider ID" });
607
672
  return;
608
673
  }
674
+ if (this.host.modelRuntime().isUsingOAuth(pid)) {
675
+ this.host.emit({
676
+ type: "notice",
677
+ level: "error",
678
+ text: "请使用 OAuth 登出操作清除当前登录",
679
+ textEn: "Use the OAuth sign-out action to clear the current login",
680
+ });
681
+ this.host.flushSnapshot();
682
+ return;
683
+ }
609
684
  try {
610
685
  // Remove from auth.json ({ <provider>: { type: "api_key", key } }).
611
686
  const authPath = join(this.host.agentDir, "auth.json");
@@ -756,12 +831,15 @@ export class ModelAdminService {
756
831
  }
757
832
  this.host.flushSnapshot();
758
833
  }
759
- /** Enumerate pi's built-in providers with auth status (key-only config). */
834
+ /** Enumerate pi's built-in providers with auth capabilities and status. */
760
835
  async listProviders() {
761
836
  const mr = this.host.modelRuntime();
762
837
  let providers;
763
838
  try {
764
839
  providers = mr.getProviders().map((p) => {
840
+ const supportsApiKey = p.auth.apiKey !== undefined;
841
+ const supportsOAuth = p.auth.oauth !== undefined;
842
+ const oauthName = p.auth.oauth?.name;
765
843
  try {
766
844
  const st = mr.getProviderAuthStatus(p.id);
767
845
  return {
@@ -769,11 +847,23 @@ export class ModelAdminService {
769
847
  name: p.name,
770
848
  configured: st?.configured ?? false,
771
849
  source: st?.source,
850
+ supportsApiKey,
851
+ supportsOAuth,
852
+ oauthName,
853
+ usingOAuth: mr.isUsingOAuth(p.id),
772
854
  };
773
855
  }
774
856
  catch {
775
857
  // One odd provider must not blank the whole list.
776
- return { id: p.id, name: p.name, configured: false };
858
+ return {
859
+ id: p.id,
860
+ name: p.name,
861
+ configured: false,
862
+ supportsApiKey,
863
+ supportsOAuth,
864
+ oauthName,
865
+ usingOAuth: false,
866
+ };
777
867
  }
778
868
  });
779
869
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 特权 DOM 访问授权表(`<dataDir>/plugin-dom.json`)。
3
+ *
4
+ * 背景:插件 client bundle 与主应用同源,JS 层面拦不住它碰 `document`——真正的门禁
5
+ * 只能放在 bundle 下发处(`/plugins/:id/client/*`)。manifest.permissions 含 "dom"
6
+ * 族的插件,其 bundle 默认 403;用户在设置面板逐个授权后才放行(并 epoch+1 让
7
+ * 浏览器重拉)。授权是整机全局的(与 plugin-grants.json 同口径),任何浏览器看到
8
+ * 的都是同一份。
9
+ *
10
+ * 语义:
11
+ * - 读不出来 / JSON 坏 / 形状不对 → 当空表,且**不在读路径回写**(一次磁盘抖动不
12
+ * 能静默清空用户授权;与 PluginGrantsStore 同一条纪律)。
13
+ * - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
14
+ * (内存态仍生效,本次会话可用)。
15
+ */
16
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ const FILE = "plugin-dom.json";
19
+ /** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
20
+ * 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
21
+ * 完整 document 访问仍需 `dom`(或除 anchor 外的其它 dom 子族)+ 用户授权。 */
22
+ export function declarationWantsDom(permissions) {
23
+ if (!Array.isArray(permissions))
24
+ return false;
25
+ return permissions.some((p) => {
26
+ if (typeof p !== "string")
27
+ return false;
28
+ const [fam, sub] = p.split(":").map((s) => s.trim().toLowerCase());
29
+ if (fam !== "dom")
30
+ return false;
31
+ return sub === undefined || sub === "" || sub !== "anchor";
32
+ });
33
+ }
34
+ /** 静态门禁判定:纯函数,单测覆盖。未知插件(wantsDom=false)一律放行——向后兼容。 */
35
+ export function isDomBundleBlocked(wantsDom, granted) {
36
+ return wantsDom && !granted;
37
+ }
38
+ function readGranted(dataDir) {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(join(dataDir, FILE), "utf8"));
41
+ const list = Array.isArray(raw?.granted) ? raw.granted : [];
42
+ return new Set(list.filter((x) => typeof x === "string" && x.length > 0));
43
+ }
44
+ catch {
45
+ return new Set();
46
+ }
47
+ }
48
+ export class PluginDomConsent {
49
+ dataDir;
50
+ granted;
51
+ constructor(dataDir) {
52
+ this.dataDir = dataDir;
53
+ this.granted = readGranted(dataDir);
54
+ }
55
+ has(pluginId) {
56
+ return this.granted.has(pluginId);
57
+ }
58
+ list() {
59
+ return [...this.granted].sort();
60
+ }
61
+ /** 授权/撤销。返回是否真的发生了变更(没变就不落盘、不推快照)。 */
62
+ set(pluginId, granted) {
63
+ const id = String(pluginId ?? "").trim();
64
+ if (!id)
65
+ return false;
66
+ const had = this.granted.has(id);
67
+ if (granted === had)
68
+ return false;
69
+ if (granted)
70
+ this.granted.add(id);
71
+ else
72
+ this.granted.delete(id);
73
+ try {
74
+ const tmp = join(this.dataDir, `${FILE}.tmp.${process.pid}`);
75
+ writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
76
+ renameSync(tmp, join(this.dataDir, FILE));
77
+ }
78
+ catch {
79
+ /* 写失败 best-effort:内存态仍生效,本次会话可用 */
80
+ }
81
+ return true;
82
+ }
83
+ }
@@ -199,10 +199,24 @@ export class PluginSecrets {
199
199
  // deps(宿主代插件自动补装运行时依赖)
200
200
  // ---------------------------------------------------------------------------
201
201
  const DEP_TIMEOUT_MS = 180_000; // 慢网安装兜底(含第一次拉取包元数据)
202
+ /** spec(`name` / `name@range` / `@scope/name@range`)→ 裸包名。
203
+ * require.resolve 不认 `@版本号` 后缀(`foo@1.2.3` 会被当成字面目录名,
204
+ * 恒判缺失 → 带版本 pin 的 ensureDeps 永远装完还报缺,voice-input 踩过)。
205
+ * 非标准形状(URL / 本地路径 / tag)原样返回,resolve 失败即判缺失,行为不变。
206
+ * 纯函数,单测覆盖。 */
207
+ export function depName(spec) {
208
+ const m = /^(?:(@[^/\s]+\/[^/\s@]+)|([^/\s@:.]+))(?:@[^/\s]*)?$/.exec(str(spec));
209
+ if (!m)
210
+ return spec;
211
+ return m[1] ?? m[2] ?? spec;
212
+ }
213
+ function str(v) {
214
+ return typeof v === "string" ? v.trim() : "";
215
+ }
202
216
  /** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。 */
203
217
  export function isDepAvailable(pluginDir, spec) {
204
218
  try {
205
- createRequire(join(pluginDir, "index.mjs")).resolve(spec);
219
+ createRequire(join(pluginDir, "index.mjs")).resolve(depName(spec));
206
220
  return true;
207
221
  }
208
222
  catch {
@@ -50,8 +50,14 @@ export function buildPluginJobArgs(spec, dataDir, lang) {
50
50
  const args = ["install", source, "--name", id, "--data-dir", dataDir];
51
51
  if (spec.action === "update")
52
52
  args.push("--force");
53
+ if (spec.build && spec.noBuild)
54
+ return {
55
+ error: pick(l, "--build 与 --no-build 不能同时用", "--build and --no-build are mutually exclusive", "plugininstaller.build.conflict"),
56
+ };
53
57
  if (spec.build)
54
58
  args.push("--build");
59
+ else if (spec.noBuild)
60
+ args.push("--no-build");
55
61
  return { args };
56
62
  }
57
63
  export class PluginInstaller {