pi-web-ui 0.95.0 → 0.96.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 (74) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +306 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-llm.js +71 -65
  38. package/dist/server/plugin-project.js +70 -2
  39. package/dist/server/plugin-updater.js +115 -12
  40. package/dist/server/plugins.js +205 -44
  41. package/dist/server/present-files-tool.js +9 -12
  42. package/dist/server/process-utils.js +4 -0
  43. package/dist/server/prompt-composer.js +9 -0
  44. package/dist/server/protocol-version.js +1 -1
  45. package/dist/server/read-tool.js +69 -30
  46. package/dist/server/resolve-global-sdk.js +30 -16
  47. package/dist/server/schedule-agent-tool.js +12 -15
  48. package/dist/server/sdk-origin.js +18 -2
  49. package/dist/server/serialize.js +108 -21
  50. package/dist/server/settings-service.js +4 -2
  51. package/dist/server/skill-tool.js +5 -7
  52. package/dist/server/subagent-templates.js +51 -0
  53. package/dist/server/subagents.js +48 -72
  54. package/dist/server/terminals.js +181 -69
  55. package/dist/server/tool-manager.js +24 -7
  56. package/dist/server/tool-overrides.js +59 -0
  57. package/dist/server/update-check.js +28 -1
  58. package/dist/server/uploads.js +17 -2
  59. package/dist/server/wait-subscription-scan.js +18 -21
  60. package/dist/server/ws-client-id.js +29 -0
  61. package/dist/server/ws-pending-queue.js +60 -0
  62. package/extensions/webui.ts +60 -2
  63. package/package.json +4 -2
  64. package/plugins/catalog.json +20 -0
  65. package/themes/aetheris.css +457 -0
  66. package/themes/zhupi-dark.css +658 -0
  67. package/themes/zhupi.css +711 -0
  68. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
  69. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  70. package/web/dist/assets/index-DryOsILO.css +1 -0
  71. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  72. package/web/dist/index.html +3 -3
  73. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  74. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -16,8 +16,12 @@
16
16
  import { spawn } from "node:child_process";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { bilingual } from "./i18n.js";
20
19
  const PROTOCOL_VERSION = "2025-03-26"; // 广泛支持的工具版本
20
+ /** 单行(一条 JSON-RPC 消息)的长度上限(1MB):失控服务器一行永不换行会顶爆内存。 */
21
+ const MCP_MAX_LINE_CHARS = 1024 * 1024;
22
+ /** 未换行数据的总缓冲上限(4MB):服务器只吐垃圾不吐换行时按协议错误杀进程。
23
+ * 上限按 JS 字符串长度计(UTF-16 码元 ≤ UTF-8 字节),内存放大有界。 */
24
+ const MCP_MAX_BUFFER_CHARS = 4 * 1024 * 1024;
21
25
  let rpcSeq = 0;
22
26
  /**
23
27
  * 单个 MCP 服务器的客户端:管理子进程、请求/响应按 id 关联、握手与工具调用。
@@ -245,12 +249,22 @@ export class McpClient {
245
249
  }
246
250
  onData(chunk) {
247
251
  this.buffer += chunk;
252
+ // 总缓冲上限:服务器一直输出却不换行(或极慢地换行)时,缓冲会无限涨,
253
+ // 宿主内存被一个失控子进程吃光。超限按协议错误处理:杀进程 + 拒绝在途请求。
254
+ if (this.buffer.length > MCP_MAX_BUFFER_CHARS) {
255
+ this.killForProtocolError(new Error(`[mcp:${this.name}] stdout 缓冲超过 ${Math.round(MCP_MAX_BUFFER_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
256
+ return;
257
+ }
248
258
  let nl;
249
259
  while ((nl = this.buffer.indexOf("\n")) >= 0) {
250
260
  const line = this.buffer.slice(0, nl).trim();
251
261
  this.buffer = this.buffer.slice(nl + 1);
252
262
  if (!line)
253
263
  continue;
264
+ if (line.length > MCP_MAX_LINE_CHARS) {
265
+ this.killForProtocolError(new Error(`[mcp:${this.name}] 单行超过 ${Math.round(MCP_MAX_LINE_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
266
+ return;
267
+ }
254
268
  let msg;
255
269
  try {
256
270
  msg = JSON.parse(line);
@@ -262,6 +276,24 @@ export class McpClient {
262
276
  this.handleMessage(msg);
263
277
  }
264
278
  }
279
+ /** 协议错误(stdout 缓冲超限):杀掉子进程、拒绝全部在途请求、清空缓冲。
280
+ * 与进程自然退出共用自愈语义 —— 下一次工具调用会重新拉起并重新握手
281
+ * (失控服务器重启后再次超限就再次杀,不会永久占用宿主内存)。 */
282
+ killForProtocolError(err) {
283
+ this.log(err.message);
284
+ this.buffer = "";
285
+ const child = this.child;
286
+ this.child = null;
287
+ this.rejectAll(err);
288
+ if (child) {
289
+ try {
290
+ child.kill();
291
+ }
292
+ catch {
293
+ /* 已退出 */
294
+ }
295
+ }
296
+ }
265
297
  handleMessage(msg) {
266
298
  if (msg.id !== undefined) {
267
299
  const pending = this.pending.get(String(msg.id));
@@ -360,8 +392,7 @@ function adaptMcpTool(serverName, mcpTool, client) {
360
392
  return {
361
393
  name,
362
394
  label: `${serverName} · ${mcpTool.name}`,
363
- description: mcpTool.description ??
364
- bilingual(`Tool ${mcpTool.name} provided by MCP server "${serverName}"`, `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`),
395
+ description: mcpTool.description ?? `Tool ${mcpTool.name} provided by MCP server "${serverName}"`,
365
396
  parameters: mcpTool.inputSchema ?? {},
366
397
  execute: async (_toolCallId, params, _signal) => {
367
398
  return client.call(mcpTool.name, params ?? {});
@@ -380,6 +411,8 @@ export class McpBridge {
380
411
  opts;
381
412
  clients = [];
382
413
  tools = [];
414
+ /** reload 串行化链:重入的 reload 排在前一个之后,杜绝交叠留下的孤儿进程。 */
415
+ reloadChain = Promise.resolve();
383
416
  constructor(dataDir, log = () => { }, opts = {}) {
384
417
  this.dataDir = dataDir;
385
418
  this.log = log;
@@ -411,6 +444,12 @@ export class McpBridge {
411
444
  }
412
445
  /**
413
446
  * 按磁盘上的最新配置**整体换入**服务器集合(`mcp.json` 热加载用),可重复调用。
447
+ *
448
+ * 并发互斥:watch + 轮询 + 手动 apply 可能同时触发 reload,两个 reload 交叠跑
449
+ * 会各自 startOne/close —— 同一服务器起两个子进程、或把别人刚换入的实例当
450
+ * stale 关掉(孤儿/误杀)。这里用一条 promise 链把重入排成队,每个 reload
451
+ * 看到的是前一个完成后的最新状态;热加载的防抖窗口之外再兜一道。
452
+ *
414
453
  * 三步的顺序都有讲究:
415
454
  * 1. 规格没变的服务器**沿用原实例** —— 改一个服务器不该连带重启其它服务器(子进程、
416
455
  * 浏览器会话、在途调用全都不动);
@@ -419,6 +458,11 @@ export class McpBridge {
419
458
  * 3. 最后才 close 掉被移除/被替换的旧实例,并按新集合重建工具表。
420
459
  */
421
460
  async reload() {
461
+ const run = this.reloadChain.then(() => this.reloadOnce(), () => this.reloadOnce());
462
+ this.reloadChain = run.then(() => undefined, () => undefined);
463
+ return run;
464
+ }
465
+ async reloadOnce() {
422
466
  const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
423
467
  const next = new Map();
424
468
  let kept = 0;
@@ -11,7 +11,7 @@
11
11
  * 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
12
12
  * UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
13
13
  */
14
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
  import { enrichBatch } from "./model-enrich.js";
17
17
  import { pick } from "./i18n.js";
@@ -65,6 +65,13 @@ export function graduateOverlayModels(providers, official) {
65
65
  }
66
66
  return graduated;
67
67
  }
68
+ /** 原子写 JSON(tmp + rename):进程崩溃不留半截文件——裸 writeFileSync 中途被
69
+ * 杀会把整份配置截断,下次读盘即空表。tmp 名带 pid,避免同机多进程互踩。 */
70
+ function atomicWriteJson(path, data) {
71
+ const tmp = `${path}.${process.pid}.tmp`;
72
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
73
+ renameSync(tmp, path);
74
+ }
68
75
  /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
69
76
  function stripJsonComments(src) {
70
77
  let out = "";
@@ -111,7 +118,7 @@ function stripJsonComments(src) {
111
118
  /** Merge a UI-submitted provider config into the existing models.json entry.
112
119
  *
113
120
  * 表单(`UiProviderConfig`)只承载 UI 认识的字段:provider 级 name/api/baseUrl/
114
- * apiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
121
+ * hasApiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
115
122
  * models.json 里还可能有 UI 不认识的字段——provider 级 headers(浏览器拿不到,
116
123
  * 见 listModelsConfig)、模型级 api/baseUrl/cost/compat/thinkingLevelMap(手写或
117
124
  * 脚本写入,pi-ai 靠它们决定请求地址与推理格式)。按表单整体重建条目会把这些字段
@@ -120,6 +127,10 @@ function stripJsonComments(src) {
120
127
  *
121
128
  * 所以这里以已有条目为底、表单字段覆盖:表单没提到的字段原样保留,表单清空的可选
122
129
  * 字段才真正删除;`models` 仍是「表单即全集」——表单里删掉的 id 会被移除。
130
+ *
131
+ * apiKey 例外:浏览器不再持有明文(只见 hasApiKey),**缺字段 = 保留旧值**;显式
132
+ * 空串才清除(协议级"空=清除"语义保留,但 Web 表单留空时整字段不传)。这样
133
+ * refresh_provider_models 等不带 apiKey 的内部保存路径也绝不会把密钥抹掉。
123
134
  */
124
135
  export function mergeProviderConfigEntry(prevEntry, config, models) {
125
136
  const prevModels = new Map();
@@ -134,7 +145,7 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
134
145
  }
135
146
  const mergedModels = models.map((model) => {
136
147
  // 旧条目同 id 的字段(api/baseUrl/cost/compat/…)先铺底,表单字段覆盖。
137
- const merged = { ...(prevModels.get(model.id) ?? {}), id: model.id };
148
+ const merged = { ...prevModels.get(model.id), id: model.id };
138
149
  if (model.name?.trim())
139
150
  merged.name = model.name.trim();
140
151
  else
@@ -167,7 +178,12 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
167
178
  apply("name", config.name?.trim() || undefined);
168
179
  apply("api", config.api?.trim() || undefined);
169
180
  apply("baseUrl", config.baseUrl?.trim() || undefined);
170
- apply("apiKey", config.apiKey?.trim() || undefined);
181
+ if (config.apiKey === undefined) {
182
+ // 表单没带 apiKey(留空/内部路径)→ 保留旧值,绝不清除。
183
+ }
184
+ else {
185
+ apply("apiKey", config.apiKey.trim() || undefined);
186
+ }
171
187
  apply("authHeader", config.authHeader ? true : undefined);
172
188
  mergedEntry.models = mergedModels;
173
189
  return mergedEntry;
@@ -331,29 +347,55 @@ export class ModelAdminService {
331
347
  }
332
348
  /** Read + parse provider-keys.json. */
333
349
  readProviderKeys() {
350
+ let raw;
334
351
  try {
335
- const parsed = JSON.parse(readFileSync(this.providerKeysPath(), "utf8"));
336
- const out = {};
337
- for (const [pid, entry] of Object.entries(parsed)) {
338
- const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
339
- if (!pid || keys.length === 0)
340
- continue;
341
- const activeKeyName = entry.activeKeyName === null
342
- ? null
343
- : entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
344
- ? entry.activeKeyName
345
- : keys[0].name;
346
- out[pid] = { activeKeyName, keys };
347
- }
348
- return out;
352
+ raw = readFileSync(this.providerKeysPath(), "utf8");
349
353
  }
350
354
  catch {
355
+ return {}; // 文件不存在 = 首次引导
356
+ }
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(raw);
360
+ }
361
+ catch (err) {
362
+ // 坏文件改名留存:里面可能有全部备用密钥,绝不能被后续种子/空表整份覆盖。
363
+ this.quarantineCorruptFile(this.providerKeysPath(), err);
351
364
  return {};
352
365
  }
366
+ const out = {};
367
+ for (const [pid, entry] of Object.entries(parsed)) {
368
+ const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
369
+ if (!pid || keys.length === 0)
370
+ continue;
371
+ const activeKeyName = entry.activeKeyName === null
372
+ ? null
373
+ : entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
374
+ ? entry.activeKeyName
375
+ : keys[0].name;
376
+ out[pid] = { activeKeyName, keys };
377
+ }
378
+ return out;
353
379
  }
354
380
  writeProviderKeys(data) {
355
381
  mkdirSync(this.host.agentDir, { recursive: true });
356
- writeFileSync(this.providerKeysPath(), JSON.stringify(data, null, 2) + "\n");
382
+ // 原子写(tmp+rename):崩溃不留半截 JSON;0o600 限权防本机其他用户读密钥
383
+ //(Windows 忽略 mode 无害,POSIX 生效)。
384
+ const path = this.providerKeysPath();
385
+ const tmp = `${path}.${process.pid}.tmp`;
386
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
387
+ renameSync(tmp, path);
388
+ }
389
+ /** 解析失败的配置文件改名留存为 <name>.corrupt-<timestamp>。改名失败(占用/
390
+ * 跨盘等)也不抛——留存是尽力而为,绝不能阻塞主流程。 */
391
+ quarantineCorruptFile(path, err) {
392
+ try {
393
+ renameSync(path, `${path}.corrupt-${Date.now()}`);
394
+ console.warn(`[model-admin] 配置文件解析失败,已改名留存:${path}(${err.message})`);
395
+ }
396
+ catch {
397
+ console.warn(`[model-admin] 配置文件解析失败且无法留存:${path}(${err.message})`);
398
+ }
357
399
  }
358
400
  /** Default name "密钥 N" for a provider's Nth key. */
359
401
  defaultKeyName(keys) {
@@ -421,12 +463,17 @@ export class ModelAdminService {
421
463
  }
422
464
  }
423
465
  /** Push the masked provider-keys map to the client. Seeds the store from any
424
- * auth.json credentials so legacy single-key setups show up immediately. */
466
+ * auth.json credentials so legacy single-key setups show up immediately.
467
+ * 只在文件不存在(首次引导)时才落盘种子;文件存在(含空表)绝不整份写回
468
+ * ——写回的来源永远是读盘结果,避免任何竞态把用户密钥覆盖掉。 */
425
469
  listProviderKeys() {
470
+ const path = this.providerKeysPath();
426
471
  const data = this.readProviderKeys();
427
- for (const pid of this.builtinProviderIds())
428
- this.seedProviderKeysFromAuth(pid, data);
429
- this.writeProviderKeys(data);
472
+ if (!existsSync(path)) {
473
+ for (const pid of this.builtinProviderIds())
474
+ this.seedProviderKeysFromAuth(pid, data);
475
+ this.writeProviderKeys(data);
476
+ }
430
477
  this.host.emit({ type: "provider_keys", ...this.providerKeysInfo(data) });
431
478
  this.host.flushSnapshot();
432
479
  }
@@ -458,7 +505,7 @@ export class ModelAdminService {
458
505
  // no file yet / unparsable — start fresh
459
506
  }
460
507
  data[pid] = { type: "api_key", key: apiKey };
461
- writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
508
+ atomicWriteJson(authPath, data);
462
509
  const mr = this.host.modelRuntime();
463
510
  await mr.setRuntimeApiKey(pid, apiKey);
464
511
  await mr.refresh({ allowNetwork: true, providers: [pid] });
@@ -689,7 +736,7 @@ export class ModelAdminService {
689
736
  // no file yet — nothing to clean
690
737
  }
691
738
  delete auth[pid];
692
- writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
739
+ atomicWriteJson(authPath, auth);
693
740
  const mr = this.host.modelRuntime();
694
741
  await mr.removeRuntimeApiKey(pid);
695
742
  await mr.refresh({ providers: [pid] });
@@ -780,7 +827,7 @@ export class ModelAdminService {
780
827
  return;
781
828
  }
782
829
  delete data[pid];
783
- writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
830
+ atomicWriteJson(authPath, data);
784
831
  // Clear every stored key so the provider returns to unconfigured.
785
832
  delete keyData[pid];
786
833
  this.writeProviderKeys(keyData);
@@ -1131,7 +1178,8 @@ export class ModelAdminService {
1131
1178
  name: p.name,
1132
1179
  api: p.api,
1133
1180
  baseUrl: p.baseUrl,
1134
- apiKey: p.apiKey,
1181
+ // apiKey 明文绝不下发浏览器,只报有无;表单留空 = 保持不变。
1182
+ hasApiKey: typeof p.apiKey === "string" && p.apiKey.trim() !== "",
1135
1183
  authHeader: p.authHeader,
1136
1184
  // headers are intentionally NOT sent to the browser — they may
1137
1185
  // contain Authorization / API-key values; kept server-side only.
@@ -1232,12 +1280,23 @@ export class ModelAdminService {
1232
1280
  * because the baseUrl is often a LAN/loopback host the browser can't reach
1233
1281
  * cross-origin) and return the advertised models. reqId is echoed back
1234
1282
  * in fetch_models_result so the UI can match concurrent requests. */
1283
+ /** 编辑表单的探测(fetch_models / test_model_connection)没带明文 apiKey 时,
1284
+ * 按 providerId 回落到 models.json 里已保存的密钥——编辑存量服务商时表单
1285
+ * 一律留空(明文不再回传浏览器),没有这个回落「测试连接」就会 401。 */
1286
+ savedProviderApiKey(providerId) {
1287
+ const pid = providerId?.trim();
1288
+ if (!pid)
1289
+ return undefined;
1290
+ const saved = this.readModelsConfig().providers[pid];
1291
+ return typeof saved?.apiKey === "string" && saved.apiKey.trim() ? saved.apiKey : undefined;
1292
+ }
1235
1293
  async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api,
1236
1294
  /** 探测抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
1237
- lang) {
1295
+ lang, providerId) {
1238
1296
  const emitError = (error) => this.host.emit({ type: "fetch_models_result", reqId, ok: false, error });
1239
1297
  try {
1240
- const models = await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api, undefined, lang);
1298
+ const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
1299
+ const models = await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
1241
1300
  this.host.emit({ type: "fetch_models_result", reqId, ok: true, models });
1242
1301
  }
1243
1302
  catch (err) {
@@ -1248,10 +1307,11 @@ export class ModelAdminService {
1248
1307
  * Lightweight connectivity and auth probe for a provider endpoint.
1249
1308
  * Measures latency in ms and returns ok/error.
1250
1309
  */
1251
- async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang) {
1310
+ async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang, providerId) {
1252
1311
  const start = Date.now();
1253
1312
  try {
1254
- await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api, undefined, lang);
1313
+ const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
1314
+ await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
1255
1315
  const latencyMs = Date.now() - start;
1256
1316
  this.host.emit({ type: "test_model_connection_result", reqId, ok: true, latencyMs });
1257
1317
  }
@@ -1545,7 +1605,7 @@ export class ModelAdminService {
1545
1605
  if (graduated.length === 0)
1546
1606
  return graduated;
1547
1607
  try {
1548
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1608
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1549
1609
  }
1550
1610
  catch {
1551
1611
  return [];
@@ -1582,7 +1642,7 @@ export class ModelAdminService {
1582
1642
  * config list + the picker. Callers emit their own notice. */
1583
1643
  async writeModelsConfigAndReload(providers, pid) {
1584
1644
  mkdirSync(this.host.agentDir, { recursive: true });
1585
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1645
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1586
1646
  // Allow a models.json entry to reuse the provider credential already
1587
1647
  // stored in auth.json. Seed the shared runtime too, because older pi-ai
1588
1648
  // versions did not always fall back to stored credentials for a
@@ -1739,7 +1799,7 @@ export class ModelAdminService {
1739
1799
  return;
1740
1800
  }
1741
1801
  delete providers[providerId];
1742
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1802
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1743
1803
  await this.host.modelRuntime().refresh();
1744
1804
  this.host.invalidatePiConfig();
1745
1805
  await this.listModelsConfig();
@@ -67,9 +67,9 @@ export function unzipFiles(buf, wanted) {
67
67
  let totalUncomp = 0;
68
68
  const out = new Map();
69
69
  for (const [name, meta] of files) {
70
- totalUncomp += meta.uncompSize;
71
- if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES)
70
+ if (meta.uncompSize > OFFICE_MAX_UNCOMPRESSED_BYTES) {
72
71
  throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
72
+ }
73
73
  const lp = meta.localOffset;
74
74
  if (buf.readUInt32LE(lp) !== 0x04034b50)
75
75
  throw new Error(`zip 局部头损坏:${name}`);
@@ -81,42 +81,88 @@ export function unzipFiles(buf, wanted) {
81
81
  if (meta.flag & 0x1)
82
82
  throw new Error(`不支持加密 zip 条目:${name}`);
83
83
  const method = lMethod || meta.method;
84
- if (method === 0)
85
- out.set(name, Buffer.from(raw));
86
- else if (method === 8)
87
- out.set(name, Buffer.from(inflateRawSync(raw)));
88
- else
84
+ let decompressed;
85
+ if (method === 0) {
86
+ decompressed = Buffer.from(raw);
87
+ }
88
+ else if (method === 8) {
89
+ const remainingQuota = OFFICE_MAX_UNCOMPRESSED_BYTES - totalUncomp;
90
+ if (remainingQuota <= 0)
91
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
92
+ try {
93
+ decompressed = Buffer.from(inflateRawSync(raw, { maxOutputLength: remainingQuota }));
94
+ }
95
+ catch (err) {
96
+ if (err.message?.includes("maxOutputLength") ||
97
+ err.code === "ERR_BUFFER_TOO_LARGE") {
98
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+ else {
89
104
  throw new Error(`不支持的压缩方式 ${method}:${name}`);
105
+ }
106
+ totalUncomp += decompressed.length;
107
+ if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES) {
108
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
109
+ }
110
+ out.set(name, decompressed);
90
111
  }
91
112
  return out;
92
113
  }
114
+ const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
115
+ /**
116
+ * 单轮解码命名实体与十/十六进制数字实体(一次扫描同时匹配三类)。
117
+ *
118
+ * 旧实现分三段 replace:`&amp;#60;` 会先被命名段解码成 `&#60;`、再被数字段
119
+ * 解码成 `<`——双重解码让转义文本"逃出"字面量(可注入标签)。单轮扫描把
120
+ * `&…;` 整体消费、解码产物不参与后续匹配:`&amp;#60;` 正确地得到 `&#60;`。
121
+ */
93
122
  function decodeEntities(s) {
94
- return String(s ?? "")
95
- .replace(/&(lt|gt|amp|quot|apos);/g, (_, e) => ({ lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" })[e])
96
- .replace(/&#(\d+);/g, (_, n) => {
97
- try {
98
- return String.fromCodePoint(Number(n));
99
- }
100
- catch {
101
- return "";
102
- }
103
- })
104
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
105
- try {
106
- return String.fromCodePoint(Number.parseInt(h, 16));
123
+ return String(s ?? "").replace(/&(?:#([0-9]+);|#x([0-9a-fA-F]+);|([a-zA-Z]+);)/g, (_whole, dec, hex, name) => {
124
+ if (dec !== undefined) {
125
+ try {
126
+ return String.fromCodePoint(Number(dec));
127
+ }
128
+ catch {
129
+ return "";
130
+ }
107
131
  }
108
- catch {
109
- return "";
132
+ if (hex !== undefined) {
133
+ try {
134
+ return String.fromCodePoint(Number.parseInt(hex, 16));
135
+ }
136
+ catch {
137
+ return "";
138
+ }
110
139
  }
140
+ // 未知的命名实体保留原文(与旧实现一致,不臆造映射)
141
+ return NAMED_ENTITIES[name ?? ""] ?? _whole;
111
142
  });
112
143
  }
113
144
  const stripTags = (s) => decodeEntities(String(s ?? "").replace(/<[^>]+>/g, ""));
145
+ /** document.xml 解压后的字节上限:超大 XML 会让段落正则扫描退化成秒级卡顿(同步事件循环被挂死)。 */
146
+ const DOCX_MAX_XML_BYTES = 20 * 1024 * 1024;
114
147
  /** docx → 段落数组。 */
115
148
  export function parseDocxParagraphs(buf) {
116
149
  const files = unzipFiles(buf, ["word/document.xml"]);
117
150
  const xml = files.get("word/document.xml")?.toString("utf8");
118
151
  if (!xml)
119
152
  throw new Error("docx 里找不到 word/document.xml");
153
+ // 预检 1:解压后过大的 document.xml 在下面的正则扫描里代价爆炸(一次性物化
154
+ // 全部段落、非贪婪匹配最坏回溯到文本末尾),直接友好报错而不是挂住进程。
155
+ if (xml.length > DOCX_MAX_XML_BYTES) {
156
+ throw new Error(`文档内容过大(document.xml 解压后 ${(xml.length / 1048576).toFixed(1)} MB,上限 20 MB),拒绝预览`);
157
+ }
158
+ // 预检 2:正常文档的段落闭合标签与开标签同量级。"只有开标签、没有闭标签"
159
+ // 的恶意结构会让非贪婪正则在每个候选起点都回溯扫描到文本末尾(O(n²)),
160
+ // 同步挂死事件循环——开标签远多于闭标签(>2 倍)且闭标签为 0 时直接报错。
161
+ const opens = xml.match(/<w:p[\s>]/g)?.length ?? 0;
162
+ const closes = xml.match(/<\/w:p>/g)?.length ?? 0;
163
+ if (opens > 0 && closes === 0) {
164
+ throw new Error("文档结构异常(段落标签大量未闭合),疑似恶意文档,拒绝预览");
165
+ }
120
166
  const paragraphs = [];
121
167
  for (const m of xml.matchAll(/<w:p[\s>][\s\S]*?<\/w:p>/g)) {
122
168
  const pXml = m[0];
@@ -160,11 +206,22 @@ function parseSharedStrings(xml) {
160
206
  }
161
207
  return out;
162
208
  }
209
+ const MAX_PARSE_ROWS = 1000;
210
+ const MAX_PARSE_COLS = 100;
163
211
  function parseSheet(xml, shared) {
164
212
  const rows = [];
213
+ let maxRowSeen = 0;
214
+ let maxColSeen = 0;
165
215
  for (const m of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
166
216
  const rowAttr = m[0].slice(0, m[0].indexOf(">"));
167
- const rNum = Number(/r="(\d+)"/.exec(rowAttr)?.[1] ?? rows.length + 1) - 1;
217
+ const rawRNum = /r="(\d+)"/.exec(rowAttr)?.[1];
218
+ const rNum = rawRNum ? Number(rawRNum) - 1 : rows.length;
219
+ if (!Number.isFinite(rNum) || rNum < 0)
220
+ continue;
221
+ maxRowSeen = Math.max(maxRowSeen, rNum + 1);
222
+ // 防 OOM:巨大行号不进行无边界预分配,仅计入总量
223
+ if (rNum >= MAX_PARSE_ROWS)
224
+ continue;
168
225
  while (rows.length <= rNum)
169
226
  rows.push([]);
170
227
  const row = rows[rNum];
@@ -173,7 +230,11 @@ function parseSheet(xml, shared) {
173
230
  const ref = /r="([^"]+)"/.exec(attrs)?.[1];
174
231
  const t = /t="([^"]+)"/.exec(attrs)?.[1];
175
232
  const pos = splitCellRef(ref ?? "");
176
- if (!pos)
233
+ if (!pos || pos.col < 0)
234
+ continue;
235
+ maxColSeen = Math.max(maxColSeen, pos.col + 1);
236
+ // 防 OOM:巨大列号不进行无边界空字符串 push
237
+ if (pos.col >= MAX_PARSE_COLS)
177
238
  continue;
178
239
  const inner = c[2];
179
240
  let val = "";
@@ -197,7 +258,11 @@ function parseSheet(xml, shared) {
197
258
  row[pos.col] = val;
198
259
  }
199
260
  }
200
- return rows;
261
+ return {
262
+ rows,
263
+ nRows: Math.max(rows.length, maxRowSeen),
264
+ nCols: Math.max(Math.max(0, ...rows.map((r) => r.length)), maxColSeen),
265
+ };
201
266
  }
202
267
  /** xlsx/xlsm → sheet 数组(名按 workbook 还原,取不到时回落 sheetN)。 */
203
268
  export function parseXlsxSheets(buf) {
@@ -227,10 +292,11 @@ export function parseXlsxSheets(buf) {
227
292
  const shared = parseSharedStrings(files.get("xl/sharedStrings.xml")?.toString("utf8"));
228
293
  return targets.map((t) => {
229
294
  const xml = files.get(t.file)?.toString("utf8");
230
- const all = xml ? parseSheet(xml, shared) : [];
231
- const fullCols = Math.max(0, ...all.map((r) => r.length));
295
+ const parsed = xml ? parseSheet(xml, shared) : { rows: [], nRows: 0, nCols: 0 };
296
+ const fullCols = parsed.nCols;
297
+ const fullRows = parsed.nRows;
232
298
  const nCols = Math.min(MAX_COLS, fullCols);
233
- const cut = all.slice(0, MAX_ROWS).map((r) => {
299
+ const cut = parsed.rows.slice(0, MAX_ROWS).map((r) => {
234
300
  const row = r.slice(0, MAX_COLS);
235
301
  while (row.length < nCols)
236
302
  row.push("");
@@ -238,10 +304,10 @@ export function parseXlsxSheets(buf) {
238
304
  });
239
305
  return {
240
306
  name: t.name,
241
- nRows: all.length,
307
+ nRows: fullRows,
242
308
  nCols: fullCols,
243
309
  rows: cut,
244
- truncated: all.length > MAX_ROWS || fullCols > MAX_COLS,
310
+ truncated: fullRows > MAX_ROWS || fullCols > MAX_COLS,
245
311
  };
246
312
  });
247
313
  }
@@ -19,21 +19,15 @@ export function makePatchTool(options) {
19
19
  return defineTool({
20
20
  name: PATCH_TOOL_NAME,
21
21
  label: "Apply Hashline patch",
22
- description: `Apply high-reliability, content-hashed, line-anchored patches to files in the workspace.
23
- Designed to prevent stale edits, line-drift, and indentation hallucination.
24
- Each file section starts with \`[path#TAG]\` (or \`[path]\` if hash is not yet known).
25
- Supports:
26
- - \`PUT N.=M:\` replace original inclusive lines N to M with following \`+TEXT\` lines.
27
- - \`PUT N*:\` replace entire syntactic block starting at line N (closing brace/indentation resolved automatically).
28
- - \`PUT <N:\` insert lines before line N (\`PUT <1:\` = head of file).
29
- - \`PUT >N:\` insert lines after line N (\`PUT >$:\` = end of file).
30
- - \`CUT N.=M [@name]\` / \`CUT N* [@name]\` delete lines and save to register.
31
- - \`PUT <N @name\` / \`PUT >N @name\` paste register.
32
- - \`REM\` delete file.
33
- - \`MV dest/path\` move/rename file.
34
- - Body rows under \`:\` headers MUST start with \`+\` (\`+TEXT\`, \`+\` for blank line).
35
- If file content diverged, the engine attempts automatic 3-way merge recovery.
36
- After successful patch, the tool returns the next anchor tag and live LSP diagnostics for subsequent edits.`,
22
+ description: `Apply content-hashed, line-anchored patches to workspace files — prevents stale edits, line drift, and indentation hallucination.
23
+ Each file section starts with \`[path#TAG]\` (or \`[path]\` if the hash is unknown yet). Operations:
24
+ - \`PUT N.=M:\` replace lines N..M (inclusive) with the following \`+TEXT\` lines.
25
+ - \`PUT N*:\` replace the whole syntactic block starting at line N (closing brace/indent auto-resolved).
26
+ - \`PUT <N:\` insert before line N (\`<1\` = head of file); \`PUT >N:\` insert after (\`>$\` = end).
27
+ - \`CUT N.=M [@name]\` / \`CUT N* [@name]\` delete lines and save to register; \`PUT <N @name\` / \`PUT >N @name\` paste it.
28
+ - \`REM\` delete file; \`MV dest/path\` move/rename file.
29
+ - Body rows under \`:\` headers MUST start with \`+\` (\`+\` alone = blank line).
30
+ On divergence the engine attempts a 3-way merge. After success the tool returns the next anchor tag and live LSP diagnostics.`,
37
31
  parameters: Type.Object({
38
32
  patch: Type.String({
39
33
  description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 权限预设相关辅助逻辑与会话回放。
3
+ */
4
+ /**
5
+ * 从会话 sessionManager entries 回放恢复最后设定的权限预设(若从未设定则返回 undefined)。
6
+ * customType 为 "permission/preset",载荷结构为 { preset: string }。
7
+ */
8
+ export function readPermissionFromSession(sm) {
9
+ try {
10
+ const mgr = sm;
11
+ if (typeof mgr?.getEntries !== "function")
12
+ return undefined;
13
+ const entries = mgr.getEntries();
14
+ for (let i = entries.length - 1; i >= 0; i--) {
15
+ const e = entries[i];
16
+ if (e?.type === "custom" && e.customType === "permission/preset" && typeof e.data?.preset === "string") {
17
+ return e.data.preset;
18
+ }
19
+ }
20
+ }
21
+ catch {
22
+ // ignore
23
+ }
24
+ return undefined;
25
+ }
@@ -54,11 +54,21 @@ export class PlanManager {
54
54
  return null;
55
55
  const currentStep = current.steps[idx];
56
56
  const nextStatus = patch.status && VALID_STATUSES.has(patch.status) ? patch.status : currentStep.status;
57
+ // patch 里剥掉 id:步骤 id 是 activeStepId / first-match 推进的锚点,
58
+ // 被改掉会让 activeStepId 悬空(指向不存在的步骤)。
59
+ const { id: _ignored, ...rest } = patch;
57
60
  const nextStep = {
58
61
  ...currentStep,
59
- ...patch,
62
+ ...rest,
60
63
  status: nextStatus,
64
+ // title/description 与 setPlan 同口径截断(200/1000),防超长内容撑爆快照。
65
+ title: String(rest.title ?? currentStep.title ?? "").slice(0, 200),
61
66
  };
67
+ const description = String(rest.description ?? currentStep.description ?? "").slice(0, 1000);
68
+ if (description)
69
+ nextStep.description = description;
70
+ else
71
+ delete nextStep.description;
62
72
  const nextSteps = [...current.steps];
63
73
  nextSteps[idx] = nextStep;
64
74
  let nextActive = current.activeStepId;