pi-web-ui 0.94.0 → 0.95.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 (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -0,0 +1,550 @@
1
+ /**
2
+ * server/approval-rules.ts
3
+ *
4
+ * 审批规则库与多维匹配引擎(Approval Rules Store & Match Engine)。
5
+ *
6
+ * 功能:
7
+ * 1. 规则数据模型(ApprovalRule / UiApprovalRule):
8
+ * - 工具名单(tools:["bash"]、["write", "edit"]、["*"])
9
+ * - 匹配字段(field:"command" | "path" | "params")
10
+ * - 匹配方式(match:"regex" | "glob" | "contains" | "prefix" | "outside_workspace")
11
+ * - 命中动作(action:"ask" | "deny" | "allow")
12
+ * - "deny":直接阻断执行并向模型报错(不弹窗、不产生 pending)
13
+ * - "ask":触发人工协同审批(弹窗让用户决定,带档位可「允许同类」)
14
+ * - "allow":免审直接放行(白名单,跳过后续规则与内置高危检测)
15
+ * - 启停开关(enabled)+ 内置标记(builtin)
16
+ * - 双语名称与拦截原因(label/labelEn, reason/reasonEn)
17
+ *
18
+ * 2. 匹配与判定纯函数(matchApprovalRule / evaluateApprovalRules):
19
+ * - 优先级规则:
20
+ * 先按规则列表自顶向下顺序遍历,遇到第一个匹配成功的启用规则即返回其判决;
21
+ * 若命中 "allow",直接免审放行;
22
+ * 若命中 "deny",直接拒绝执行;
23
+ * 若命中 "ask",挂起审批弹窗;
24
+ * 若所有规则均未命中,回落至内置默认安全策略(兼容旧硬编码逻辑)。
25
+ *
26
+ * 3. 存储与多客户端同步(ApprovalRulesStore):
27
+ * - 存储路径:<dataDir>/approval-rules.json(所有客户端共享同一份)
28
+ * - 播种 sidecar:<dataDir>/approval-rules.seeded.json(记录已播种过的内置规则 id,
29
+ * 老用户删掉的内置规则不会在重启后复活;发版新增的内置规则可安全合并进老用户文件)。
30
+ * - 内存缓存 + 文件 mtime 感知,支持直接编辑 JSON 文件热生效。
31
+ */
32
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
33
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
34
+ /**
35
+ * 极简 glob 转 RegExp(只支持 * 单段、? 单字符、** 跨段)。
36
+ * 纯函数,支持正反斜杠统一归一为 /。
37
+ */
38
+ export function globToRegex(pattern) {
39
+ const src = String(pattern ?? "")
40
+ .trim()
41
+ .replace(/\\/g, "/");
42
+ let re = "";
43
+ for (let i = 0; i < src.length; i++) {
44
+ const c = src[i];
45
+ if (c === "*") {
46
+ if (src[i + 1] === "*") {
47
+ if (src[i + 2] === "/") {
48
+ re += "(?:.*/)?";
49
+ i += 2;
50
+ }
51
+ else {
52
+ re += ".*";
53
+ i += 1;
54
+ }
55
+ }
56
+ else {
57
+ re += "[^/]*";
58
+ }
59
+ }
60
+ else if (c === "?") {
61
+ re += "[^/]";
62
+ }
63
+ else {
64
+ re += c.replace(/[.+^${}()|[\]\\]/, (m) => `\\${m}`);
65
+ }
66
+ }
67
+ return new RegExp(`^${re}$`, "i");
68
+ }
69
+ /**
70
+ * 从工具参数对象中提取待检查文本。
71
+ */
72
+ export function extractRuleFieldValue(field, toolName, params) {
73
+ if (!params || typeof params !== "object") {
74
+ return typeof params === "string" ? params : "";
75
+ }
76
+ const obj = params;
77
+ if (field === "command") {
78
+ return String(obj.command ?? "");
79
+ }
80
+ if (field === "path") {
81
+ return String(obj.path ?? "");
82
+ }
83
+ if (field === "params") {
84
+ try {
85
+ return JSON.stringify(params);
86
+ }
87
+ catch {
88
+ return String(params);
89
+ }
90
+ }
91
+ return "";
92
+ }
93
+ /**
94
+ * 单条规则针对具体工具调用的匹配判定(纯函数)。
95
+ */
96
+ export function matchApprovalRule(rule, toolName, params, cwd, workspaceRoots = []) {
97
+ if (!rule.enabled)
98
+ return false;
99
+ // 1. 工具名匹配
100
+ const targetTools = rule.tools.map((t) => t.trim().toLowerCase());
101
+ const toolMatches = targetTools.includes("*") || targetTools.includes(toolName.trim().toLowerCase());
102
+ if (!toolMatches)
103
+ return false;
104
+ // 2. 特殊匹配方式:outside_workspace(工作区外路径)
105
+ if (rule.match === "outside_workspace") {
106
+ const targetPath = extractRuleFieldValue("path", toolName, params);
107
+ if (!targetPath)
108
+ return false;
109
+ const abs = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
110
+ const allRoots = [resolve(cwd), ...workspaceRoots.map((r) => resolve(r))];
111
+ const inside = allRoots.some((r) => abs === r || abs.startsWith(r + sep));
112
+ return !inside;
113
+ }
114
+ // 3. 提取字段文本
115
+ const fieldValue = extractRuleFieldValue(rule.field, toolName, params);
116
+ // 4. 按 match kind 进行文本判定
117
+ switch (rule.match) {
118
+ case "regex": {
119
+ try {
120
+ const re = new RegExp(rule.value, "i");
121
+ return re.test(fieldValue);
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ case "glob": {
128
+ try {
129
+ const re = globToRegex(rule.value);
130
+ return re.test(fieldValue.replace(/\\/g, "/"));
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ case "contains": {
137
+ return fieldValue.toLowerCase().includes(rule.value.toLowerCase());
138
+ }
139
+ case "prefix": {
140
+ return fieldValue.toLowerCase().startsWith(rule.value.toLowerCase());
141
+ }
142
+ default:
143
+ return false;
144
+ }
145
+ }
146
+ /**
147
+ * 完整评估规则列表对工具调用的决策(纯函数,按列表顺序首个匹配胜出)。
148
+ */
149
+ export function evaluateApprovalRules(rules, toolName, params, cwd, workspaceRoots = []) {
150
+ for (const rule of rules) {
151
+ if (matchApprovalRule(rule, toolName, params, cwd, workspaceRoots)) {
152
+ const catId = rule.categoryId || rule.id;
153
+ const category = {
154
+ id: catId,
155
+ label: rule.label,
156
+ labelEn: rule.labelEn || rule.label,
157
+ };
158
+ return {
159
+ action: rule.action,
160
+ matchedRule: rule,
161
+ reason: rule.reason || rule.label,
162
+ reasonEn: rule.reasonEn || rule.labelEn || rule.label,
163
+ category,
164
+ };
165
+ }
166
+ }
167
+ return { action: "none" };
168
+ }
169
+ /**
170
+ * 内置默认审批规则(与 server/tool-approval.ts 既有内置检测表保持 100% 同口径与同一 id)。
171
+ */
172
+ export const DEFAULT_APPROVAL_RULES = [
173
+ {
174
+ id: "builtin.bash.rm-rf",
175
+ enabled: true,
176
+ tools: ["bash"],
177
+ field: "command",
178
+ match: "regex",
179
+ value: "\\brm\\s+-[a-zA-Z0-9]*[rf][a-zA-Z0-9]*\\s+((\\/)|(~)|(\\.\\.)|(\\*)|(\\.\\/))",
180
+ action: "ask",
181
+ label: "递归/强制删除 (rm -rf)",
182
+ labelEn: "Recursive/force delete (rm -rf)",
183
+ reason: "检测到高风险的递归/强制删除大范围路径命令 (rm -rf)",
184
+ reasonEn: "Detected high-risk recursive/force deletion of wide paths (rm -rf)",
185
+ categoryId: "bash.rm-rf",
186
+ builtin: true,
187
+ },
188
+ {
189
+ id: "builtin.bash.win-del",
190
+ enabled: true,
191
+ tools: ["bash"],
192
+ field: "command",
193
+ match: "regex",
194
+ value: "\\b(del|rmdir)\\s+\\/[fsq]",
195
+ action: "ask",
196
+ label: "Windows 强制删除 (del/rmdir)",
197
+ labelEn: "Windows force delete (del/rmdir)",
198
+ reason: "检测到高风险的 Windows 强制/递归删除目录命令 (del/rmdir /s /q)",
199
+ reasonEn: "Detected high-risk Windows force/recursive deletion command (del/rmdir /s /q)",
200
+ categoryId: "bash.win-del",
201
+ builtin: true,
202
+ },
203
+ {
204
+ id: "builtin.bash.disk",
205
+ enabled: true,
206
+ tools: ["bash"],
207
+ field: "command",
208
+ match: "regex",
209
+ value: "\\b(mkfs|format\\s+[a-zA-Z]:|dd\\s+if=)",
210
+ action: "ask",
211
+ label: "磁盘格式化/底层写入",
212
+ labelEn: "Disk format / raw block write",
213
+ reason: "检测到磁盘格式化或底层块写入危险命令 (format/mkfs/dd)",
214
+ reasonEn: "Detected dangerous disk formatting or raw block write command (format/mkfs/dd)",
215
+ categoryId: "bash.disk",
216
+ builtin: true,
217
+ },
218
+ {
219
+ id: "builtin.bash.git-destructive",
220
+ enabled: true,
221
+ tools: ["bash"],
222
+ field: "command",
223
+ match: "regex",
224
+ value: "\\bgit\\s+(push\\s+.*?(--force|-[a-zA-Z0-9]*f)\\b|reset\\s+--hard|clean\\s+-[a-zA-Z0-9]*f|branch\\s+-[dD]\\b)",
225
+ action: "ask",
226
+ label: "破坏性 Git 操作",
227
+ labelEn: "Destructive git operation",
228
+ reason: "检测到不可逆的破坏性 Git 操作 (force push / reset --hard / clean -f)",
229
+ reasonEn: "Detected irreversible destructive Git operation (force push / reset --hard / clean -f)",
230
+ categoryId: "bash.git-destructive",
231
+ builtin: true,
232
+ },
233
+ {
234
+ id: "builtin.bash.chmod",
235
+ enabled: true,
236
+ tools: ["bash"],
237
+ field: "command",
238
+ match: "regex",
239
+ value: "\\bchmod\\s+(-R\\s+)?(777|000)\\b",
240
+ action: "ask",
241
+ label: "危险权限修改 (chmod)",
242
+ labelEn: "Dangerous permission change (chmod)",
243
+ reason: "检测到过度开放或全局破坏性的文件权限修改 (chmod 777/000)",
244
+ reasonEn: "Detected overly permissive or globally destructive file permission change (chmod 777/000)",
245
+ categoryId: "bash.chmod",
246
+ builtin: true,
247
+ },
248
+ {
249
+ id: "builtin.bash.system-redirect",
250
+ enabled: true,
251
+ tools: ["bash"],
252
+ field: "command",
253
+ match: "regex",
254
+ value: ">\\s*(\\/etc\\/|\\/boot\\/|C:\\\\Windows)",
255
+ action: "ask",
256
+ label: "写入系统关键目录",
257
+ labelEn: "Write into system directories",
258
+ reason: "检测到重定向写入系统关键目录的危险操作",
259
+ reasonEn: "Detected dangerous redirection writing into critical system directories",
260
+ categoryId: "bash.system-redirect",
261
+ builtin: true,
262
+ },
263
+ {
264
+ id: "builtin.file.sensitive.env",
265
+ enabled: true,
266
+ tools: ["write", "edit", "edit_soft"],
267
+ field: "path",
268
+ match: "regex",
269
+ value: "(^|[\\/\\\\])\\.env(\\.[a-zA-Z0-9_-]+)?$",
270
+ action: "ask",
271
+ label: "敏感配置 (.env)",
272
+ labelEn: "Sensitive config (.env)",
273
+ reason: "尝试修改敏感环境变量/密钥配置文件 (.env)",
274
+ reasonEn: "Attempting to modify sensitive environment/secret configuration (.env)",
275
+ categoryId: "file.sensitive.env",
276
+ builtin: true,
277
+ },
278
+ {
279
+ id: "builtin.file.sensitive.ssh",
280
+ enabled: true,
281
+ tools: ["write", "edit", "edit_soft"],
282
+ field: "path",
283
+ match: "regex",
284
+ value: "(^|[\\/\\\\])(id_rsa|id_ed25519|authorized_keys|known_hosts)$",
285
+ action: "ask",
286
+ label: "SSH 密钥/凭据",
287
+ labelEn: "SSH keys / credentials",
288
+ reason: "尝试修改 SSH 密钥或认证凭据文件",
289
+ reasonEn: "Attempting to modify SSH keys or authentication credentials",
290
+ categoryId: "file.sensitive.ssh",
291
+ builtin: true,
292
+ },
293
+ {
294
+ id: "builtin.file.sensitive.shell",
295
+ enabled: true,
296
+ tools: ["write", "edit", "edit_soft"],
297
+ field: "path",
298
+ match: "regex",
299
+ value: "(^|[\\/\\\\])(\\.bashrc|\\.zshrc|\\.profile|\\.bash_profile)$",
300
+ action: "ask",
301
+ label: "Shell 启动配置",
302
+ labelEn: "Shell profile",
303
+ reason: "尝试修改用户全局 Shell 启动配置文件",
304
+ reasonEn: "Attempting to modify user global Shell profile configuration",
305
+ categoryId: "file.sensitive.shell",
306
+ builtin: true,
307
+ },
308
+ {
309
+ id: "builtin.file.outside-workspace",
310
+ enabled: true,
311
+ tools: ["write", "edit", "edit_soft"],
312
+ field: "path",
313
+ match: "outside_workspace",
314
+ value: "",
315
+ action: "ask",
316
+ label: "工作区外写入",
317
+ labelEn: "Write outside workspace",
318
+ reason: "尝试在工作区外部写入/修改文件",
319
+ reasonEn: "Attempting to write/modify file outside the workspace",
320
+ categoryId: "file.outside-workspace",
321
+ builtin: true,
322
+ },
323
+ ];
324
+ /** 归一化输入规则。脏数据或非法规则返回 null。 */
325
+ export function normalizeApprovalRule(raw) {
326
+ if (!raw || typeof raw !== "object")
327
+ return null;
328
+ const o = raw;
329
+ const id = typeof o.id === "string" ? o.id.trim() : "";
330
+ if (!id || id.length > 120)
331
+ return null;
332
+ const label = typeof o.label === "string" ? o.label.trim() : "";
333
+ if (!label || label.length > 120)
334
+ return null;
335
+ const labelEn = typeof o.labelEn === "string" && o.labelEn.trim() ? o.labelEn.trim() : undefined;
336
+ const reason = typeof o.reason === "string" && o.reason.trim() ? o.reason.trim() : undefined;
337
+ const reasonEn = typeof o.reasonEn === "string" && o.reasonEn.trim() ? o.reasonEn.trim() : undefined;
338
+ const action = o.action === "deny" ? "deny" : o.action === "allow" ? "allow" : "ask";
339
+ const field = o.field === "path" ? "path" : o.field === "params" ? "params" : "command";
340
+ const match = o.match === "glob"
341
+ ? "glob"
342
+ : o.match === "contains"
343
+ ? "contains"
344
+ : o.match === "prefix"
345
+ ? "prefix"
346
+ : o.match === "outside_workspace"
347
+ ? "outside_workspace"
348
+ : "regex";
349
+ const value = typeof o.value === "string" ? o.value : "";
350
+ if (match === "regex") {
351
+ try {
352
+ new RegExp(value);
353
+ }
354
+ catch {
355
+ return null; // 非法正则拒绝
356
+ }
357
+ }
358
+ const tools = Array.isArray(o.tools)
359
+ ? o.tools
360
+ .filter((t) => typeof t === "string" && t.trim().length > 0)
361
+ .map((t) => t.trim().toLowerCase())
362
+ : ["*"];
363
+ return {
364
+ id,
365
+ enabled: o.enabled !== false,
366
+ tools: tools.length > 0 ? tools : ["*"],
367
+ field,
368
+ match,
369
+ value,
370
+ action,
371
+ label,
372
+ ...(labelEn ? { labelEn } : {}),
373
+ ...(reason ? { reason } : {}),
374
+ ...(reasonEn ? { reasonEn } : {}),
375
+ ...(typeof o.categoryId === "string" && o.categoryId.trim() ? { categoryId: o.categoryId.trim() } : {}),
376
+ builtin: o.builtin === true,
377
+ };
378
+ }
379
+ /** 全局审批规则库持久化与操作类。 */
380
+ export class ApprovalRulesStore {
381
+ filePath;
382
+ rules = null;
383
+ lastMtime = 0;
384
+ constructor(filePath) {
385
+ this.filePath = filePath;
386
+ }
387
+ seededPath() {
388
+ return /\.json$/i.test(this.filePath)
389
+ ? this.filePath.replace(/\.json$/i, ".seeded.json")
390
+ : `${this.filePath}.seeded.json`;
391
+ }
392
+ loadSeeded() {
393
+ const out = new Set();
394
+ try {
395
+ const parsed = JSON.parse(readFileSync(this.seededPath(), "utf8"));
396
+ if (Array.isArray(parsed)) {
397
+ for (const item of parsed) {
398
+ if (typeof item === "string" && item)
399
+ out.add(item);
400
+ }
401
+ }
402
+ }
403
+ catch {
404
+ // 文件不存在时返回空集合
405
+ }
406
+ return out;
407
+ }
408
+ saveSeeded(names) {
409
+ try {
410
+ mkdirSync(dirname(this.seededPath()), { recursive: true });
411
+ const tmp = `${this.seededPath()}.${process.pid}.tmp`;
412
+ writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2) + "\n");
413
+ renameSync(tmp, this.seededPath());
414
+ }
415
+ catch {
416
+ // best effort
417
+ }
418
+ }
419
+ load() {
420
+ let currentMtime = 0;
421
+ try {
422
+ if (existsSync(this.filePath)) {
423
+ currentMtime = statSync(this.filePath).mtimeMs;
424
+ }
425
+ }
426
+ catch {
427
+ currentMtime = 0;
428
+ }
429
+ if (this.rules && currentMtime > 0 && currentMtime === this.lastMtime) {
430
+ return this.rules;
431
+ }
432
+ let list = [];
433
+ try {
434
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
435
+ if (Array.isArray(parsed)) {
436
+ list = parsed.map(normalizeApprovalRule).filter((r) => r !== null);
437
+ }
438
+ }
439
+ catch {
440
+ // 文件不存在或格式损坏,以默认规则初始化
441
+ list = DEFAULT_APPROVAL_RULES.map((r) => ({ ...r, tools: [...r.tools] }));
442
+ }
443
+ // 检查播种:老用户新增内置规则自动合并
444
+ const seeded = this.loadSeeded();
445
+ const existingIds = new Set(list.map((r) => r.id));
446
+ let grown = false;
447
+ for (const def of DEFAULT_APPROVAL_RULES) {
448
+ if (!existingIds.has(def.id) && !seeded.has(def.id)) {
449
+ list.push({ ...def, tools: [...def.tools] });
450
+ existingIds.add(def.id);
451
+ grown = true;
452
+ }
453
+ seeded.add(def.id);
454
+ }
455
+ if (grown || seeded.size > 0) {
456
+ this.saveSeeded(seeded);
457
+ }
458
+ this.rules = list;
459
+ this.lastMtime = currentMtime;
460
+ return this.rules;
461
+ }
462
+ persist() {
463
+ try {
464
+ mkdirSync(dirname(this.filePath), { recursive: true });
465
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
466
+ writeFileSync(tmp, JSON.stringify(this.rules ?? [], null, 2) + "\n");
467
+ renameSync(tmp, this.filePath);
468
+ try {
469
+ this.lastMtime = statSync(this.filePath).mtimeMs;
470
+ }
471
+ catch {
472
+ // ignore
473
+ }
474
+ }
475
+ catch {
476
+ // best effort
477
+ }
478
+ }
479
+ /** 获取全部有效规则清单(深拷贝返回)。 */
480
+ list() {
481
+ return this.load().map((r) => ({ ...r, tools: [...r.tools] }));
482
+ }
483
+ /** Upsert 一条规则(同 id 替换,新 id 追加到列表末尾)。返回错误提示,成功返回 null。 */
484
+ upsert(input) {
485
+ const rule = normalizeApprovalRule(input);
486
+ if (!rule)
487
+ return "规则格式非法(缺少名称、工具列表或正则格式错误)";
488
+ const list = this.load();
489
+ const idx = list.findIndex((r) => r.id === rule.id);
490
+ if (idx >= 0) {
491
+ // 内置规则保留 builtin 标记
492
+ if (list[idx].builtin) {
493
+ rule.builtin = true;
494
+ }
495
+ list[idx] = rule;
496
+ }
497
+ else {
498
+ list.push(rule);
499
+ }
500
+ this.persist();
501
+ return null;
502
+ }
503
+ /** 批量重排/替换整份规则(供前端拖拽排序后保存)。 */
504
+ saveAll(inputs) {
505
+ if (!Array.isArray(inputs))
506
+ return "规则列表必须是数组";
507
+ const normalized = [];
508
+ const ids = new Set();
509
+ for (const raw of inputs) {
510
+ const r = normalizeApprovalRule(raw);
511
+ if (!r)
512
+ return "存在格式非法的规则项";
513
+ if (ids.has(r.id))
514
+ return `规则 id 冲突: ${r.id}`;
515
+ ids.add(r.id);
516
+ normalized.push(r);
517
+ }
518
+ this.rules = normalized;
519
+ this.persist();
520
+ return null;
521
+ }
522
+ /** 删除一条自定义规则(内置规则不可删除)。 */
523
+ remove(id) {
524
+ const list = this.load();
525
+ const idx = list.findIndex((r) => r.id === id);
526
+ if (idx < 0)
527
+ return false;
528
+ if (list[idx].builtin)
529
+ return false; // 内置规则不许直接删除,只允许禁用
530
+ list.splice(idx, 1);
531
+ this.persist();
532
+ return true;
533
+ }
534
+ /** 恢复某条内置规则到系统默认设定。 */
535
+ resetBuiltin(id) {
536
+ const def = DEFAULT_APPROVAL_RULES.find((r) => r.id === id);
537
+ if (!def)
538
+ return false;
539
+ const list = this.load();
540
+ const idx = list.findIndex((r) => r.id === id);
541
+ if (idx >= 0) {
542
+ list[idx] = { ...def, tools: [...def.tools] };
543
+ }
544
+ else {
545
+ list.push({ ...def, tools: [...def.tools] });
546
+ }
547
+ this.persist();
548
+ return true;
549
+ }
550
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * attachment-store.ts — 基于 SHA-256 内容寻址(CAS)的附件存储。
3
+ *
4
+ * 借鉴 DSH 的 dsh-attachment 机制:
5
+ * - 图片与大二进制附件以 sha256 哈希落盘到 `<dataDir>/attachments/<hash>.<ext>`;
6
+ * - WebSocket 快照与会话转录中仅传递紧凑的 hash 引用与 URL;
7
+ * - 浏览器通过 `/api/attachment/:hash` 经 HTTP 访问,带永久强缓存(immutable);
8
+ * - 避免快照和消息列表随会话进行出现数兆字节 base64 的膨胀。
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { mkdirSync, existsSync, readdirSync } from "node:fs";
12
+ import { readFile, writeFile, stat } from "node:fs/promises";
13
+ import { join, extname } from "node:path";
14
+ let storeDir = "";
15
+ /** 初始化附件存储目录(服务启动时调用)。 */
16
+ export function initAttachmentStore(dataDir) {
17
+ storeDir = join(dataDir, "attachments");
18
+ if (!existsSync(storeDir)) {
19
+ mkdirSync(storeDir, { recursive: true });
20
+ }
21
+ return storeDir;
22
+ }
23
+ const MIME_EXT_MAP = {
24
+ "image/png": ".png",
25
+ "image/jpeg": ".jpg",
26
+ "image/jpg": ".jpg",
27
+ "image/gif": ".gif",
28
+ "image/webp": ".webp",
29
+ "image/svg+xml": ".svg",
30
+ "image/bmp": ".bmp",
31
+ "application/pdf": ".pdf",
32
+ };
33
+ const EXT_MIME_MAP = {
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".gif": "image/gif",
38
+ ".webp": "image/webp",
39
+ ".svg": "image/svg+xml",
40
+ ".bmp": "image/bmp",
41
+ ".pdf": "application/pdf",
42
+ };
43
+ /**
44
+ * 将二进制内容写入附件存储(内容寻址,幂等):
45
+ * 已存在同哈希文件则直接返回元数据,不重复写盘。
46
+ */
47
+ export async function saveAttachment(buffer, mimeType = "application/octet-stream", preferredExt) {
48
+ if (!storeDir) {
49
+ throw new Error("AttachmentStore has not been initialized with initAttachmentStore(dataDir)");
50
+ }
51
+ const hash = createHash("sha256").update(buffer).digest("hex");
52
+ const ext = preferredExt || MIME_EXT_MAP[mimeType] || ".bin";
53
+ const fileName = `${hash}${ext}`;
54
+ const filePath = join(storeDir, fileName);
55
+ if (!existsSync(filePath)) {
56
+ await writeFile(filePath, buffer);
57
+ }
58
+ return {
59
+ hash,
60
+ size: buffer.length,
61
+ mimeType,
62
+ ext,
63
+ url: `/api/attachment/${hash}`,
64
+ filePath,
65
+ };
66
+ }
67
+ /**
68
+ * 根据哈希查找已存储的附件文件路径及元数据。
69
+ */
70
+ export async function findAttachment(hash) {
71
+ if (!storeDir || !existsSync(storeDir))
72
+ return null;
73
+ // 校验 hash 必须是 64 位十六进制字符串(防止目录遍历)
74
+ if (!/^[a-f0-9]{64}$/i.test(hash))
75
+ return null;
76
+ try {
77
+ const files = readdirSync(storeDir);
78
+ const match = files.find((f) => f.startsWith(hash));
79
+ if (!match)
80
+ return null;
81
+ const filePath = join(storeDir, match);
82
+ const st = await stat(filePath);
83
+ const ext = extname(match).toLowerCase();
84
+ const mimeType = EXT_MIME_MAP[ext] || "application/octet-stream";
85
+ return {
86
+ hash,
87
+ size: st.size,
88
+ mimeType,
89
+ ext,
90
+ url: `/api/attachment/${hash}`,
91
+ filePath,
92
+ };
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * 读取附件二进制数据。
100
+ */
101
+ export async function readAttachment(hash) {
102
+ const record = await findAttachment(hash);
103
+ if (!record)
104
+ return null;
105
+ try {
106
+ const buffer = await readFile(record.filePath);
107
+ return { buffer, mimeType: record.mimeType };
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }