pi-web-ui 0.94.1 → 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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,305 @@
1
+ /**
2
+ * manifest 本体校验:schema 失败即拒(DSH 对照 P1-6)。
3
+ *
4
+ * 与 `parseUiItem` / `parseSettingsSchema` 的"宽容丢弃"正交:
5
+ * 那些是字段级容错(坏条目跳过、文本截断),这里是**结构级否决**
6
+ * (坏 manifest 不该变成运行时 `undefined` 再在远处 nổ)。
7
+ *
8
+ * 语义对齐既有契约:
9
+ * - 旧插件(无 `permissions` 且 `apiVersion<2`)走宽容通道:只拦
10
+ * JSON 坏 / 非对象 / apiVersion 非法这类"连启动都不该启动"的;
11
+ * 未知能力族、v2 必填缺失等只在严格模式下才算 error。
12
+ * - `errors` 非空 → 拒绝激活(scan 置 error + activate 首行回卷);
13
+ * `warnings` 永不阻断,只进 `diagnostics` 供设置面板查看。
14
+ */
15
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
16
+ /** 与 `plugin-sdk/index.d.ts#PluginPermissionFamily` + 运行时 `requestPermission` 同口径。 */
17
+ export const KNOWN_PERMISSION_FAMILIES = new Set([
18
+ "fs",
19
+ "fs:read",
20
+ "fs:write",
21
+ "ui",
22
+ "tools",
23
+ "http",
24
+ "chat",
25
+ "net",
26
+ "llm",
27
+ "dom",
28
+ "dom:anchor",
29
+ ]);
30
+ function isRecord(v) {
31
+ return Boolean(v) && typeof v === "object" && !Array.isArray(v);
32
+ }
33
+ /** 能力串是否已知:完整串或冒号前族名命中其一即算(如 "ui:read" 归 ui 族)。 */
34
+ export function isKnownPermission(v) {
35
+ return KNOWN_PERMISSION_FAMILIES.has(v) || KNOWN_PERMISSION_FAMILIES.has(v.split(":")[0]);
36
+ }
37
+ export function validatePluginManifest(raw, dirName) {
38
+ const errors = [];
39
+ const warnings = [];
40
+ if (!isRecord(raw)) {
41
+ errors.push({
42
+ path: "manifest",
43
+ message: "manifest.json 必须是对象",
44
+ messageEn: "manifest.json must be an object",
45
+ });
46
+ return { errors, warnings, apiVersion: 1, permissions: [], strict: false };
47
+ }
48
+ const o = raw;
49
+ // id:缺省回落目录名(向后兼容,只警告);写了就必须合法且与目录一致。
50
+ const idRaw = o.id;
51
+ if (idRaw === undefined) {
52
+ // 旧插件大量不写 id,不警告(避免全仓黄灯),仅回落。
53
+ }
54
+ else if (typeof idRaw !== "string" || !idRaw.trim()) {
55
+ warnings.push({
56
+ path: "id",
57
+ message: "id 非字符串,已回落为目录名",
58
+ messageEn: 'invalid "id", fell back to directory name',
59
+ });
60
+ }
61
+ else if (!ID_RE.test(idRaw.trim())) {
62
+ errors.push({
63
+ path: "id",
64
+ message: `id「${idRaw.trim().slice(0, 32)}」非法(只允许字母数字/_/-)`,
65
+ messageEn: `invalid "id" (only [A-Za-z0-9_-] allowed)`,
66
+ });
67
+ }
68
+ else if (idRaw.trim() !== dirName) {
69
+ errors.push({
70
+ path: "id",
71
+ message: `id「${idRaw.trim()}」与目录名「${dirName}」不一致`,
72
+ messageEn: `"id" does not match directory name "${dirName}"`,
73
+ });
74
+ }
75
+ // apiVersion:缺省 1(旧插件常态,不警告);写了必须是 1..2 整数。
76
+ let apiVersion = 1;
77
+ const av = o.apiVersion;
78
+ if (av === undefined) {
79
+ apiVersion = 1;
80
+ }
81
+ else if (typeof av !== "number" || !Number.isInteger(av) || av < 1) {
82
+ errors.push({
83
+ path: "apiVersion",
84
+ message: "apiVersion 必须是 >=1 的整数",
85
+ messageEn: '"apiVersion" must be an integer >= 1',
86
+ });
87
+ apiVersion = 1;
88
+ }
89
+ else {
90
+ apiVersion = av;
91
+ }
92
+ // permissions:非数组即错;条目逐个校验。
93
+ let permissions = [];
94
+ const perms = o.permissions;
95
+ if (perms === undefined) {
96
+ permissions = [];
97
+ }
98
+ else if (!Array.isArray(perms)) {
99
+ errors.push({
100
+ path: "permissions",
101
+ message: "permissions 必须是字符串数组",
102
+ messageEn: '"permissions" must be an array of strings',
103
+ });
104
+ }
105
+ else {
106
+ if (perms.length > 16) {
107
+ warnings.push({
108
+ path: "permissions",
109
+ message: `permissions 超出 16 个,多余的将被忽略`,
110
+ messageEn: '"permissions" capped at 16 entries',
111
+ });
112
+ }
113
+ for (let i = 0; i < Math.min(perms.length, 16); i++) {
114
+ const p = perms[i];
115
+ const path = `permissions[${i}]`;
116
+ if (typeof p !== "string" || !p.trim()) {
117
+ errors.push({ path, message: "能力声明不能为空", messageEn: "capability must be a non-empty string" });
118
+ continue;
119
+ }
120
+ const v = p.trim();
121
+ if (!isKnownPermission(v)) {
122
+ // 未知族:拼写错了就永远授权失败,与静默丢弃同源 —— 直接拒。
123
+ errors.push({
124
+ path,
125
+ message: `未知能力「${v.slice(0, 32)}」(可选 ${[...KNOWN_PERMISSION_FAMILIES].join("/")})`,
126
+ messageEn: `unknown capability "${v.slice(0, 32)}"`,
127
+ });
128
+ continue;
129
+ }
130
+ permissions.push(v);
131
+ }
132
+ }
133
+ const strict = permissions.length > 0 || apiVersion >= 2;
134
+ // v2 必须声明 permissions(与 §2.5 严格模式同口径)。只对宿主认识的版本强制:
135
+ // apiVersion > 2 交给版本门出“请升级”(见 activate/scan),这里不抢它的错误信息。
136
+ if (apiVersion === 2 && permissions.length === 0 && perms === undefined) {
137
+ errors.push({
138
+ path: "permissions",
139
+ message: "apiVersion 2 的插件必须声明 permissions",
140
+ messageEn: 'apiVersion 2 plugins must declare "permissions"',
141
+ });
142
+ }
143
+ // engines:只收字符串映射;坏形状即错(否则约束被静默吞掉)。
144
+ if (o.engines !== undefined) {
145
+ if (!isRecord(o.engines)) {
146
+ errors.push({ path: "engines", message: "engines 必须是对象", messageEn: '"engines" must be an object' });
147
+ }
148
+ else {
149
+ for (const [k, v] of Object.entries(o.engines).slice(0, 8)) {
150
+ if (typeof v !== "string") {
151
+ errors.push({
152
+ path: `engines.${k}`,
153
+ message: "engines 约束值必须是字符串",
154
+ messageEn: '"engines" constraint values must be strings',
155
+ });
156
+ }
157
+ }
158
+ }
159
+ }
160
+ // requires(P2-8 硬依赖):坏形状即错(依赖判定错不得,静默吞掉等于回到 peerPlugins 的老路)。
161
+ if (o.requires !== undefined) {
162
+ if (!isRecord(o.requires)) {
163
+ errors.push({ path: "requires", message: "requires 必须是对象", messageEn: '"requires" must be an object' });
164
+ }
165
+ else {
166
+ const r = o.requires;
167
+ if (r.hostApi !== undefined &&
168
+ (typeof r.hostApi !== "number" || !Number.isInteger(r.hostApi) || r.hostApi < 1)) {
169
+ errors.push({
170
+ path: "requires.hostApi",
171
+ message: "requires.hostApi 必须是 >=1 的整数",
172
+ messageEn: '"requires.hostApi" must be an integer >= 1',
173
+ });
174
+ }
175
+ if (r.families !== undefined) {
176
+ if (!Array.isArray(r.families)) {
177
+ errors.push({
178
+ path: "requires.families",
179
+ message: "requires.families 必须是字符串数组",
180
+ messageEn: '"requires.families" must be an array of strings',
181
+ });
182
+ }
183
+ else {
184
+ if (r.families.length > 8) {
185
+ warnings.push({
186
+ path: "requires.families",
187
+ message: "requires.families 超出 8 个,多余的将被忽略",
188
+ messageEn: '"requires.families" capped at 8 entries',
189
+ });
190
+ }
191
+ for (let i = 0; i < Math.min(r.families.length, 8); i++) {
192
+ const f = r.families[i];
193
+ const path = `requires.families[${i}]`;
194
+ if (typeof f !== "string" || !f.trim()) {
195
+ errors.push({ path, message: "能力族不能为空", messageEn: "family must be a non-empty string" });
196
+ }
197
+ else if (!isKnownPermission(f.trim())) {
198
+ errors.push({
199
+ path,
200
+ message: `未知能力族「${f.trim().slice(0, 32)}」(拼写?或需要更新 pi-web-ui)`,
201
+ messageEn: `unknown family "${f.trim().slice(0, 32)}" (typo? or needs newer pi-web-ui)`,
202
+ });
203
+ }
204
+ }
205
+ }
206
+ }
207
+ if (r.plugins !== undefined) {
208
+ if (!Array.isArray(r.plugins)) {
209
+ errors.push({
210
+ path: "requires.plugins",
211
+ message: "requires.plugins 必须是插件 id 数组",
212
+ messageEn: '"requires.plugins" must be an array of plugin ids',
213
+ });
214
+ }
215
+ else {
216
+ if (r.plugins.length > 16) {
217
+ warnings.push({
218
+ path: "requires.plugins",
219
+ message: "requires.plugins 超出 16 个,多余的将被忽略",
220
+ messageEn: '"requires.plugins" capped at 16 entries',
221
+ });
222
+ }
223
+ for (let i = 0; i < Math.min(r.plugins.length, 16); i++) {
224
+ const dep = r.plugins[i];
225
+ if (typeof dep !== "string" || !ID_RE.test(dep.trim())) {
226
+ errors.push({
227
+ path: `requires.plugins[${i}]`,
228
+ message: "插件 id 非法(只允许字母数字/_/-)",
229
+ messageEn: "invalid plugin id (only [A-Za-z0-9_-] allowed)",
230
+ });
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ // view / preload:坏类型只警告(不影响安全语义,回落缺省)。
238
+ for (const key of ["view", "preload"]) {
239
+ if (o[key] !== undefined && typeof o[key] !== "boolean") {
240
+ warnings.push({
241
+ path: key,
242
+ message: `${key} 必须是布尔值,已回落缺省`,
243
+ messageEn: `"${key}" must be a boolean, fell back to default`,
244
+ });
245
+ }
246
+ }
247
+ // 文本字段:坏类型只警告(回落缺省,不阻断)。
248
+ for (const key of ["name", "version", "description", "icon"]) {
249
+ if (o[key] !== undefined && typeof o[key] !== "string") {
250
+ warnings.push({
251
+ path: key,
252
+ message: `${key} 必须是字符串,已忽略`,
253
+ messageEn: `"${key}" must be a string, ignored`,
254
+ });
255
+ }
256
+ }
257
+ // 数组型声明:坏形状只警告(解析层已按"过滤+截断"处理)。
258
+ for (const key of [
259
+ "netAllowlist",
260
+ "peerPlugins",
261
+ "renderers",
262
+ "messageWidgets",
263
+ "attachmentCards",
264
+ "composerProviders",
265
+ ]) {
266
+ if (o[key] !== undefined && !Array.isArray(o[key])) {
267
+ warnings.push({
268
+ path: key,
269
+ message: `${key} 必须是数组,已忽略`,
270
+ messageEn: `"${key}" must be an array, ignored`,
271
+ });
272
+ }
273
+ }
274
+ if (o.settings !== undefined && !Array.isArray(o.settings) && !isRecord(o.settings)) {
275
+ warnings.push({
276
+ path: "settings",
277
+ message: "settings 必须是数组或对象,已忽略",
278
+ messageEn: '"settings" must be an array or object, ignored',
279
+ });
280
+ }
281
+ if (o.fileHandlers !== undefined && !Array.isArray(o.fileHandlers)) {
282
+ warnings.push({
283
+ path: "fileHandlers",
284
+ message: "fileHandlers 必须是数组,已忽略",
285
+ messageEn: '"fileHandlers" must be an array, ignored',
286
+ });
287
+ }
288
+ // ui:有声明却无 ui 能力(严格模式)= 整份不会生效 —— 算 error 而不是等解析层那条英文诊断。
289
+ // 同样只对宿主认识的版本(apiVersion<=2)强制,未来版本交给版本门。
290
+ if (o.ui !== undefined && strict && apiVersion <= 2 && !permissions.some((x) => x.split(":")[0] === "ui")) {
291
+ errors.push({
292
+ path: "ui",
293
+ message: '声明了 ui 却未声明 "ui" 能力,整份 ui 不会生效',
294
+ messageEn: 'manifest declares "ui" but lacks the "ui" capability — the whole ui section is ignored',
295
+ });
296
+ }
297
+ if (o.ui !== undefined && typeof o.ui !== "object") {
298
+ errors.push({ path: "ui", message: "ui 必须是对象", messageEn: '"ui" must be an object' });
299
+ }
300
+ return { errors, warnings, apiVersion, permissions, strict };
301
+ }
302
+ /** 诊断行渲染(scan/activate 共用):`path: 中文(en)`。 */
303
+ export function formatManifestIssue(i) {
304
+ return `${i.path}: ${i.message} (${i.messageEn})`;
305
+ }
@@ -24,7 +24,8 @@
24
24
  */
25
25
  import { spawn } from "node:child_process";
26
26
  import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
27
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
27
+ import { readdir as fspReaddir } from "node:fs/promises";
28
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
28
29
  import { StringDecoder } from "node:string_decoder";
29
30
  import { killPidTree } from "./process-utils.js";
30
31
  /** 单条 git 命令的默认墙钟上限:大仓库 clone 可能很慢,给足 5 分钟。 */
@@ -91,8 +92,9 @@ function realpathOf(p) {
91
92
  * 目标自身(存在时)或它最近一个**已存在的祖先**的真实路径;一路到盘根都解析不出来 → null。
92
93
  * 用途是复核符号链接:`<root>/link/file.txt` 里 link 若是指向别处的 junction,
93
94
  * 单看字符串它「在 root 里」,realpath 之后就露馅了。
95
+ * 导出给 plugin-facilities / plugins.ts 的写类操作复用(同一套越界复核语义)。
94
96
  */
95
- function realPathOfNearest(abs) {
97
+ export function realPathOfNearest(abs) {
96
98
  let probe = abs;
97
99
  for (let i = 0; i < MAX_ANCESTOR_HOPS; i++) {
98
100
  try {
@@ -111,6 +113,28 @@ function realPathOfNearest(abs) {
111
113
  function toSlash(p) {
112
114
  return p.split(sep).join("/");
113
115
  }
116
+ /**
117
+ * 递归列目录,返回树内全部符号链接 / junction 的绝对路径。
118
+ * win32 上 Dirent.isSymbolicLink() 同样覆盖 junction(lstat 语义);只读不跟随。
119
+ * 目录读不了(权限/竞态删除)返回空数组 —— 兜底交给写入前的逐目标 realpath 复核。
120
+ */
121
+ async function findSymlinks(dir) {
122
+ const out = [];
123
+ let entries;
124
+ try {
125
+ entries = await fspReaddir(dir, { recursive: true, withFileTypes: true });
126
+ }
127
+ catch {
128
+ return out;
129
+ }
130
+ for (const e of entries) {
131
+ if (!e.isSymbolicLink())
132
+ continue;
133
+ const parent = e.parentPath ?? dir;
134
+ out.push(join(parent, e.name));
135
+ }
136
+ return out;
137
+ }
114
138
  function errMessage(err) {
115
139
  return err instanceof Error ? err.message : String(err);
116
140
  }
@@ -191,26 +215,62 @@ async function runGit(bin, args, opts) {
191
215
  child.stdout?.on("data", (chunk) => stdout.push(chunk));
192
216
  child.stderr?.on("data", (chunk) => stderr.push(chunk));
193
217
  let timedOut = false;
194
- const timer = setTimeout(() => {
195
- timedOut = true;
196
- if (typeof child.pid === "number" && child.pid > 0)
197
- killPidTree(child.pid);
198
- else
199
- child.kill("SIGKILL");
200
- }, opts.timeoutMs);
218
+ let settled = false;
219
+ let graceTimer;
201
220
  let spawnError;
202
- child.on("error", (err) => {
203
- spawnError = `无法启动 git(${bin}):${errMessage(err)}`;
204
- });
205
- child.on("close", (code) => {
221
+ const finish = (code) => {
222
+ if (settled)
223
+ return;
224
+ settled = true;
206
225
  clearTimeout(timer);
226
+ if (graceTimer)
227
+ clearTimeout(graceTimer);
207
228
  stdout.flush();
208
229
  stderr.flush();
209
230
  let tail = stderrLines.join("\n");
210
231
  if (tail.length > MAX_STDERR_TAIL)
211
232
  tail = `…${tail.slice(-MAX_STDERR_TAIL)}`;
212
233
  settle({ code: code ?? -1, timedOut, stderrTail: tail, ...(spawnError ? { spawnError } : {}) });
234
+ };
235
+ const timer = setTimeout(() => {
236
+ timedOut = true;
237
+ if (typeof child.pid === "number" && child.pid > 0) {
238
+ void killPidTree(child.pid);
239
+ // On Windows taskkill is asynchronous and a child may not emit close
240
+ // promptly. Directly kill the git process too, then force settlement
241
+ // after a short grace period so callers never hang on close.
242
+ try {
243
+ child.kill("SIGKILL");
244
+ }
245
+ catch {
246
+ /* already dead */
247
+ }
248
+ }
249
+ else {
250
+ try {
251
+ child.kill("SIGKILL");
252
+ }
253
+ catch {
254
+ /* already dead */
255
+ }
256
+ }
257
+ graceTimer = setTimeout(() => {
258
+ try {
259
+ child.stdout?.destroy();
260
+ child.stderr?.destroy();
261
+ }
262
+ catch {
263
+ /* already closed */
264
+ }
265
+ finish(-1);
266
+ }, 500);
267
+ }, opts.timeoutMs);
268
+ child.on("error", (err) => {
269
+ spawnError = `无法启动 git(${bin}):${errMessage(err)}`;
270
+ if (timedOut)
271
+ finish(-1);
213
272
  });
273
+ child.on("close", (code) => finish(code));
214
274
  });
215
275
  }
216
276
  /**
@@ -358,6 +418,28 @@ export async function createProject(spec, deps = {}) {
358
418
  return undefined;
359
419
  };
360
420
  try {
421
+ // 本轮真正 clone 出的目录(根目录本身除外 —— 它是授权目录,绝不清理)与
422
+ // 已写入的文件,供「clone 后置复核命中」时回滚,不把半个带毒仓库留在盘上。
423
+ const clonedThisRun = plannedRepos.filter((r) => !samePath(r.dest, root)).map((r) => r.dest);
424
+ const writtenThisRun = [];
425
+ const cleanupThisRun = () => {
426
+ for (const f of writtenThisRun) {
427
+ try {
428
+ rmSync(f, { force: true });
429
+ }
430
+ catch {
431
+ /* 清理尽力而为 */
432
+ }
433
+ }
434
+ for (const d of clonedThisRun) {
435
+ try {
436
+ rmSync(d, { recursive: true, force: true });
437
+ }
438
+ catch {
439
+ /* 清理尽力而为 */
440
+ }
441
+ }
442
+ };
361
443
  for (const r of plannedRepos) {
362
444
  const atRoot = samePath(r.dest, root);
363
445
  // 根目录已存在是常态(它就是那个已存在的授权目录),既不 rm 也不 mkdir ——
@@ -384,10 +466,32 @@ export async function createProject(spec, deps = {}) {
384
466
  if (why)
385
467
  return fail(`${why}${res.stderrTail === "" ? "" : `:${res.stderrTail}`}`);
386
468
  }
469
+ // ——— ⑥ clone 后置复核(写文件前):前置校验(③④)都在 clone 之前,而恶意
470
+ // 仓库可以在 clone 时落地指向 root 之外的符号链接,随后的文件写入就会跟着
471
+ // 链接跑出授权目录(「先 clone 后校验」的逃逸窗口)。两道防线:
472
+ // a. 扫描 clone 出的目录树,出现任何符号链接即视为恶意仓库整体拒绝
473
+ // (clone --depth 1 不跑包管理器,正常仓库极少提交 symlink,宁可误杀);
474
+ // b. 每个写入目标写前再做一次 realPathOfNearest 复核(覆盖 a 与写盘之间的窗口)。
475
+ // 任一命中:清理本轮 clone 的目录(rmSync 不跟随符号链接,安全)与已写文件。
476
+ for (const dest of clonedThisRun) {
477
+ const offenders = await findSymlinks(dest);
478
+ if (offenders.length > 0) {
479
+ cleanupThisRun();
480
+ return fail(`仓库包含符号链接(视为恶意仓库拒绝),例如:${toSlash(relative(root, offenders[0]))}`);
481
+ }
482
+ }
387
483
  for (const f of plannedFiles) {
484
+ // 写前复核目标仍解析在 rootReal 内:前置校验时它还不存在(最近祖先在 root 内),
485
+ // clone 之后可能被仓库塞进来的符号链接顶掉。
486
+ const real = realPathOfNearest(f.abs);
487
+ if (real === null || !isInsideRoot(rootReal, real)) {
488
+ cleanupThisRun();
489
+ return fail(`files 路径越界(clone 后复核:符号链接指向 dir 之外):${f.rel}`);
490
+ }
388
491
  emit(`write ${f.rel}`);
389
492
  mkdirSync(dirname(f.abs), { recursive: true });
390
493
  writeFileSync(f.abs, f.content, "utf8");
494
+ writtenThisRun.push(f.abs);
391
495
  }
392
496
  if (spec?.gitInit === true) {
393
497
  emit("git init");
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 插件工具拦截扩展点:类型化 Decision(DSH 对照 P1-5)。
3
+ *
4
+ * 只做 **pre + post 两阶段**、只覆盖**已接管**的工具(`bash` / `read`),
5
+ * 不照搬 DSH 的五阶段 —— 工具执行在 pi SDK 内部,只有这两处有插手面
6
+ * (`agent-service.ts` 的 customTools 覆盖 + 终端接管 bash)。
7
+ *
8
+ * - pre:`allow` 放行 / `deny` 拒绝(带原因)/ `ask` 需要用户确认。
9
+ * 首个非 allow 胜出;handler 抛错或超时一律降级为弃权(allow),
10
+ * 与 DSH chain 的「select 抛错降级为弃权」同源。
11
+ * - post:变换内容(脱敏/改写)/ 附加 `additionalContext`(自动补上下文)。
12
+ * 逐个顺序合并,抛错的那个被跳过。
13
+ * - `ask` 的完整审批 UI(弹框等人点)本次不做:按阻断处理,原因里注明
14
+ * 待确认,模型能看到、人能在诊断里看到;以后要做审批流只改
15
+ * `plugins.ts#evaluateToolPre` 的 ask 分支,不用动类型。
16
+ */
17
+ /** 单个守卫的最长等待:超时按弃权处理(不能让一个插件挂住整轮工具调用)。 */
18
+ export const GUARD_HANDLER_TIMEOUT_MS = 5000;
19
+ /** 附加上下文的上限(字符):插件补的上下文不能无节制进 LLM 上下文。 */
20
+ export const GUARD_CONTEXT_CAP = 2000;
21
+ /** 归一 pre 返回:void/null/垃圾一律按 allow(弃权);只认三决策的形状。 */
22
+ export function normalizePreDecision(raw) {
23
+ if (!raw || typeof raw !== "object")
24
+ return { decision: "allow" };
25
+ const d = raw.decision;
26
+ if (d === "deny" || d === "ask") {
27
+ const o = raw;
28
+ const reason = typeof o.reason === "string" ? o.reason.slice(0, 500) : undefined;
29
+ const reasonEn = typeof o.reasonEn === "string" ? o.reasonEn.slice(0, 500) : undefined;
30
+ return d === "deny" ? { decision: "deny", reason, reasonEn } : { decision: "ask", reason, reasonEn };
31
+ }
32
+ return { decision: "allow" };
33
+ }
34
+ /** 是否阻断执行(deny/ask 都算阻断;ask 只是原因不同)。 */
35
+ export function isBlockingDecision(d) {
36
+ return d.decision === "deny" || d.decision === "ask";
37
+ }
38
+ function pickText(zh, en, fbZh, fbEn, lang) {
39
+ if (lang === "zh")
40
+ return zh?.trim() ? zh.trim() : fbZh;
41
+ return en?.trim() ? en.trim() : fbEn;
42
+ }
43
+ /** 阻断时给模型看的一句话(ask 注明待确认 + 按拒绝处理)。 */
44
+ export function denialText(d, pluginId, lang) {
45
+ if (d.decision === "ask") {
46
+ return pickText(d.reason, d.reasonEn, `插件 ${pluginId} 要求先确认再执行,已按拒绝处理(审批 UI 尚未实现,ask 暂按 deny 执行)`, `Plugin ${pluginId} asked for confirmation before execution; treated as denied (approval UI not implemented yet, ask behaves as deny)`, lang);
47
+ }
48
+ return pickText(d.decision === "deny" ? d.reason : undefined, d.decision === "deny" ? d.reasonEn : undefined, `插件 ${pluginId} 拒绝了本次工具调用`, `Plugin ${pluginId} denied this tool call`, lang);
49
+ }
50
+ /** 归一 post 返回:void/null/垃圾 → undefined(无编辑);content 坏形状时只保留上下文。 */
51
+ export function normalizePostEdit(raw) {
52
+ if (!raw || typeof raw !== "object")
53
+ return undefined;
54
+ const o = raw;
55
+ let content;
56
+ if (Array.isArray(o.content)) {
57
+ const rows = o.content.filter((p) => Boolean(p) && typeof p === "object" && typeof p.type === "string");
58
+ if (rows.length > 0 && rows.length === o.content.length) {
59
+ content = rows.map((p) => ({
60
+ type: p.type.slice(0, 32),
61
+ ...(typeof p.text === "string" ? { text: p.text } : {}),
62
+ }));
63
+ }
64
+ else if (rows.length > 0) {
65
+ content = rows.map((p) => ({
66
+ type: p.type.slice(0, 32),
67
+ ...(typeof p.text === "string" ? { text: p.text } : {}),
68
+ }));
69
+ }
70
+ }
71
+ const additionalContext = typeof o.additionalContext === "string" && o.additionalContext.trim()
72
+ ? o.additionalContext.slice(0, GUARD_CONTEXT_CAP)
73
+ : undefined;
74
+ const additionalContextEn = typeof o.additionalContextEn === "string" && o.additionalContextEn.trim()
75
+ ? o.additionalContextEn.slice(0, GUARD_CONTEXT_CAP)
76
+ : undefined;
77
+ if (!content && !additionalContext && !additionalContextEn)
78
+ return undefined;
79
+ return {
80
+ ...(content ? { content } : {}),
81
+ ...(additionalContext ? { additionalContext } : {}),
82
+ ...(additionalContextEn ? { additionalContextEn } : {}),
83
+ };
84
+ }
85
+ /** 把一次 post 编辑合进执行结果(返回新对象,不改原结果)。 */
86
+ export function applyPostEdit(result, edit, lang) {
87
+ const ctx = lang === "zh"
88
+ ? edit.additionalContext?.trim()
89
+ : (edit.additionalContextEn?.trim() ?? edit.additionalContext?.trim());
90
+ const tail = ctx ? [{ type: "text", text: ctx }] : [];
91
+ if (edit.content) {
92
+ return { ...result, content: [...edit.content, ...tail] };
93
+ }
94
+ if (!tail.length)
95
+ return result;
96
+ return { ...result, content: [...(result.content ?? []), ...tail] };
97
+ }
98
+ /** 带超时的守卫调用:超时按弃权(pre→allow,post→undefined),调用方再归一。 */
99
+ export function withGuardTimeout(p, ms = GUARD_HANDLER_TIMEOUT_MS) {
100
+ let timer;
101
+ const timeout = new Promise((resolve) => {
102
+ timer = setTimeout(() => resolve(undefined), ms);
103
+ });
104
+ return Promise.race([p, timeout]).finally(() => {
105
+ if (timer)
106
+ clearTimeout(timer);
107
+ });
108
+ }
109
+ /** 封存请求参数:浅冻一层(插件拿到的是快照,改了也不影响执行)。 */
110
+ export function freezeParams(params) {
111
+ if (params && typeof params === "object" && !Object.isFrozen(params)) {
112
+ try {
113
+ Object.freeze(params);
114
+ }
115
+ catch {
116
+ // 冻不住(如 sealed 变体)就原样给:决策只读快照,不依赖冻结成功。
117
+ }
118
+ }
119
+ return params;
120
+ }