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
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, write
11
11
  import { join } from "node:path";
12
12
  import { execFile } from "node:child_process";
13
13
  import { pick } from "./i18n.js";
14
+ import { parseInstallSpec, manifestCandidateUrls } from "./plugin-install-spec.js";
14
15
  const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
15
16
  /** 保留的备份份数(超出删除最旧的)。 */
16
17
  export const BACKUP_KEEP = 3;
@@ -150,10 +151,66 @@ export async function resolveRemoteSha(spec, exec = execGit) {
150
151
  const sha = res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1] ?? null;
151
152
  return sha ? sha.slice(0, 12) : null;
152
153
  }
153
- /** 扫描全部已装插件,对比本地 sha 与远端 sha,报告更新状态。 */
154
+ /** 简易数字 semver 比较:>0 代表 a 比 b 新。 */
155
+ export function compareVersions(a, b) {
156
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
157
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
158
+ for (let i = 0; i < 3; i++) {
159
+ const x = pa[i] ?? 0;
160
+ const y = pb[i] ?? 0;
161
+ if (x !== y)
162
+ return x - y;
163
+ }
164
+ return 0;
165
+ }
166
+ /** 判断某个插件是否属于随包维护的内置插件(官方插件)。 */
167
+ export function isBuiltinPlugin(id, source, builtinCatalogPath) {
168
+ if (source && /xing-shuyin\/pi-web-ui\/plugins\//i.test(source))
169
+ return true;
170
+ if (builtinCatalogPath && existsSync(builtinCatalogPath)) {
171
+ try {
172
+ const raw = JSON.parse(readFileSync(builtinCatalogPath, "utf8"));
173
+ if (Array.isArray(raw)) {
174
+ return raw.some((e) => e?.id === id);
175
+ }
176
+ }
177
+ catch {
178
+ /* ignore parse errors */
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+ /** 探测远端 manifest 中的版本号(通过 raw.githubusercontent.com 或注入的 fetcher)。 */
184
+ async function fetchRemoteVersion(source, fetcher) {
185
+ const spec = parseInstallSpec(source);
186
+ const urls = manifestCandidateUrls(spec);
187
+ if (urls.length === 0)
188
+ return null;
189
+ const fetchImpl = fetcher ?? (typeof fetch === "function" ? fetch : null);
190
+ if (!fetchImpl)
191
+ return null;
192
+ for (const url of urls) {
193
+ try {
194
+ const ctrl = new AbortController();
195
+ const timer = setTimeout(() => ctrl.abort(), 6000);
196
+ const res = await fetchImpl(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer));
197
+ if (!res.ok)
198
+ continue;
199
+ const data = (await res.json());
200
+ if (typeof data?.version === "string" && data.version.trim()) {
201
+ return data.version.trim();
202
+ }
203
+ }
204
+ catch {
205
+ // 单个 URL 失败继续尝试下一个候选
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+ /** 扫描全部已装插件,对比本地 sha/version 与远端 sha/version,报告更新状态。 */
154
211
  export async function checkPluginUpdates(dataDir, exec = execGit,
155
212
  /** error 字段文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
156
- lang) {
213
+ lang, opts) {
157
214
  const l = lang?.() ?? "en";
158
215
  const pluginsDir = join(dataDir, "plugins");
159
216
  let names = [];
@@ -180,6 +237,42 @@ lang) {
180
237
  catch {
181
238
  localSha = null; // 无 sha 记录 → 保守认为可更新(不知道装了哪个版本)
182
239
  }
240
+ let name;
241
+ let version;
242
+ try {
243
+ const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
244
+ name = m.name;
245
+ version = m.version;
246
+ }
247
+ catch {
248
+ /* 坏 manifest:仍报告 */
249
+ }
250
+ const builtin = isBuiltinPlugin(n, source, opts?.builtinCatalogPath);
251
+ let latestVersion = null;
252
+ // 本地开发模式下,如果宿主包自带 plugins/<id>/manifest.json,可直接读本地最新版本号
253
+ if (opts?.pkgRoot) {
254
+ const localPkgManifest = join(opts.pkgRoot, "plugins", n, "manifest.json");
255
+ if (existsSync(localPkgManifest)) {
256
+ try {
257
+ const rawPkg = JSON.parse(readFileSync(localPkgManifest, "utf8"));
258
+ if (typeof rawPkg?.version === "string" && rawPkg.version.trim()) {
259
+ latestVersion = rawPkg.version.trim();
260
+ }
261
+ }
262
+ catch {
263
+ /* ignore */
264
+ }
265
+ }
266
+ }
267
+ // 若本地未取到最新版本号,则尝试通过 fetcher 探测远端 manifest.json
268
+ if (!latestVersion) {
269
+ try {
270
+ latestVersion = await fetchRemoteVersion(source, opts?.fetcher);
271
+ }
272
+ catch {
273
+ latestVersion = null;
274
+ }
275
+ }
183
276
  let remoteSha = null;
184
277
  let error;
185
278
  try {
@@ -189,27 +282,37 @@ lang) {
189
282
  error = err instanceof Error ? err.message : String(err);
190
283
  remoteSha = null;
191
284
  }
192
- if (!remoteSha && !error)
285
+ if (!remoteSha && !latestVersion && !error)
193
286
  error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
194
- let name;
195
- let version;
196
- try {
197
- const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
198
- name = m.name;
199
- version = m.version;
287
+ let updatable = false;
288
+ if (latestVersion && version) {
289
+ const cmp = compareVersions(latestVersion, version);
290
+ if (cmp > 0) {
291
+ updatable = true;
292
+ }
293
+ else if (cmp === 0 && remoteSha && localSha) {
294
+ updatable = localSha !== remoteSha;
295
+ }
296
+ else if (cmp === 0 && remoteSha && !localSha) {
297
+ updatable = true;
298
+ }
200
299
  }
201
- catch {
202
- /* 坏 manifest:仍报告 */
300
+ else if (remoteSha) {
301
+ updatable = !localSha || localSha !== remoteSha;
302
+ }
303
+ else if (latestVersion && !version) {
304
+ updatable = true;
203
305
  }
204
- const updatable = !!remoteSha && (!localSha || localSha !== remoteSha);
205
306
  out.push({
206
307
  id: n,
207
308
  name,
208
309
  version,
310
+ latestVersion,
209
311
  source,
210
312
  localSha,
211
313
  remoteSha,
212
314
  updatable,
315
+ builtin,
213
316
  error,
214
317
  });
215
318
  }
@@ -21,7 +21,7 @@ import { existsSync, readFileSync, renameSync, watch as fsWatch, writeFileSync }
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { pathToFileURL, fileURLToPath } from "node:url";
23
23
  import { pick } from "./i18n.js";
24
- import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
24
+ import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS, withFileRmwLock } from "./plugin-facilities.js";
25
25
  import { parseCronSpec, armDelay, nextCronFire, loadScheduleRecords, saveScheduleRecords, } from "./plugin-schedule.js";
26
26
  import { PluginPermissionStore } from "./plugin-permissions.js";
27
27
  import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
@@ -34,7 +34,7 @@ import { AGENT_TOOL_CATALOG } from "./tool-manager.js";
34
34
  import { applyPostEdit, denialText, freezeParams, isBlockingDecision, normalizePostEdit, normalizePreDecision, withGuardTimeout, } from "./plugin-tool-guard.js";
35
35
  // 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
36
36
  import { normalizeWorkspaceRoots } from "./client-state.js";
37
- import { createProject } from "./plugin-project.js";
37
+ import { createProject, isInsideRoot, realPathOfNearest, } from "./plugin-project.js";
38
38
  import { createHash } from "node:crypto";
39
39
  import { execFile } from "node:child_process";
40
40
  import { promisify } from "node:util";
@@ -958,16 +958,20 @@ lang, secrets) {
958
958
  if (!secretKeys.has(k))
959
959
  persist[k] = v;
960
960
  const file = join(dir, "storage.json");
961
- let existing = {};
962
- try {
963
- existing = JSON.parse(readFileSync(file, "utf8"));
964
- }
965
- catch {
966
- /* 首次 */
967
- }
968
- const tmp = `${file}.tmp-${process.pid}`;
969
- writeFileSync(tmp, JSON.stringify({ ...existing, settings: persist }));
970
- renameSync(tmp, file);
961
+ // 与 PluginStorage.set/delete 同一把按文件路径的 RMW 锁(见 plugin-facilities.ts
962
+ // 的 withFileRmwLock):两个「读-改-写」者串行,谁也不会拿旧快照抹掉对方刚写的键。
963
+ withFileRmwLock(file, () => {
964
+ let existing = {};
965
+ try {
966
+ existing = JSON.parse(readFileSync(file, "utf8"));
967
+ }
968
+ catch {
969
+ /* 首次 */
970
+ }
971
+ const tmp = `${file}.tmp-${process.pid}`;
972
+ writeFileSync(tmp, JSON.stringify({ ...existing, settings: persist }));
973
+ renameSync(tmp, file);
974
+ });
971
975
  }
972
976
  catch (err) {
973
977
  console.error(`[plugins] settings persist failed (${dir}):`, err);
@@ -1219,6 +1223,17 @@ export class PluginManager {
1219
1223
  this.senders.add(s);
1220
1224
  return () => this.senders.delete(s);
1221
1225
  }
1226
+ /** 当前在线(hello 过)的 clientId 去重集合。插件授权弹窗广播后按它做应答
1227
+ * 来源绑定——广播后才连上的端没见过弹窗,不许代答(index.ts 三类授权应答校验)。 */
1228
+ onlineClientIds() {
1229
+ const out = new Set();
1230
+ for (const s of this.senders) {
1231
+ const cid = s.cid();
1232
+ if (cid)
1233
+ out.add(cid);
1234
+ }
1235
+ return [...out];
1236
+ }
1222
1237
  /** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。
1223
1238
  * 插件代码不可信——同步抛错与返回的 Promise rejection 都必须隔离在
1224
1239
  * 这里,绝不能炸主进程。 */
@@ -1385,6 +1400,11 @@ export class PluginManager {
1385
1400
  return false;
1386
1401
  return !this.domConsentStore.has(pluginId);
1387
1402
  }
1403
+ /** plugin_dom_consent 两步握手的预检(index.ts 用):只有声明了 dom 能力的
1404
+ * 插件才允许生成在途 consent 请求——任意 pluginId 进不来,在途表不膨胀。 */
1405
+ isDomPlugin(pluginId) {
1406
+ return this.domWants.get(pluginId) === true;
1407
+ }
1388
1408
  /** 特权 DOM 授权/撤销(设置面板 plugin_dom_consent)。
1389
1409
  * 返回 { changed }:变了才 epoch+1 重推(浏览器按新 epoch 重拉 bundle,
1390
1410
  * 失败缓存随 syncPluginViews 的 epoch 切换清掉);目标不是 wantsDom 插件
@@ -1642,13 +1662,37 @@ export class PluginManager {
1642
1662
  }
1643
1663
  /** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
1644
1664
  getActiveConversation() {
1665
+ let snap;
1645
1666
  try {
1646
- return this.conversationProvider?.() ?? null;
1667
+ snap = this.conversationProvider?.() ?? null;
1647
1668
  }
1648
1669
  catch (err) {
1649
1670
  console.error("[plugins] conversationProvider failed:", err);
1650
1671
  return null;
1651
1672
  }
1673
+ if (!snap)
1674
+ return null;
1675
+ // 交给插件前做防御性拷贝:provider 回的是快照管线的**活引用** —— messages
1676
+ // 数组与元素对象都被 60ms 推送管线缓存复用(lastMessagesArray / uiMessageCache),
1677
+ // 插件原地改一个字段/挪一个元素,污染的就是推给真实客户端的快照。浅拷数组 +
1678
+ // 元素对象 structuredClone;元素是纯 JSON 结构(details 已过 JSON.stringify 闸),
1679
+ // structuredClone 不会失败,JSON 往返只是万一携带不可克隆值时的兜底。
1680
+ return {
1681
+ ...snap,
1682
+ messages: snap.messages.map((m) => {
1683
+ try {
1684
+ return structuredClone(m);
1685
+ }
1686
+ catch {
1687
+ try {
1688
+ return JSON.parse(JSON.stringify(m));
1689
+ }
1690
+ catch {
1691
+ return { ...m }; // 连 JSON 往返都失败(循环引用):至少不共享顶层对象
1692
+ }
1693
+ }
1694
+ }),
1695
+ };
1652
1696
  }
1653
1697
  /** agent-service 调:当前打开对话变了(切历史会话/切 running 对话/新对话)——
1654
1698
  * 轨迹类插件靠它重拉时间线(异常隔离)。 */
@@ -1726,13 +1770,23 @@ export class PluginManager {
1726
1770
  out.push(...table.values());
1727
1771
  return out;
1728
1772
  }
1729
- /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。 */
1773
+ /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
1774
+ * 跨插件重名同样拒绝(与 registerCommand 同口径):工具进会话时按名字合入
1775
+ * (syncPluginToolsIntoSession 的 byName 覆盖),重名意味着后注册者静默抢注
1776
+ * 前者的工具,必须在这里挡下。 */
1730
1777
  registerAgentTool(pluginId, tool) {
1731
1778
  if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
1732
1779
  console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
1733
1780
  this.pushRuntimeDiag(pluginId, "registerAgentTool: missing name/description/execute, ignored");
1734
1781
  return () => { };
1735
1782
  }
1783
+ for (const [pid, other] of this.agentTools) {
1784
+ if (pid !== pluginId && other.has(tool.name)) {
1785
+ console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 已被插件 ${pid} 注册,忽略重复`);
1786
+ this.pushRuntimeDiag(pluginId, `agent tool "${tool.name}": already registered by plugin ${pid}, ignored`);
1787
+ return () => { };
1788
+ }
1789
+ }
1736
1790
  let table = this.agentTools.get(pluginId);
1737
1791
  if (!table)
1738
1792
  this.agentTools.set(pluginId, (table = new Map()));
@@ -2596,6 +2650,23 @@ export class PluginManager {
2596
2650
  return abs;
2597
2651
  throw new Error(`目录未授权:先 await host.fs.requestAccess(dir)(${abs})`);
2598
2652
  };
2653
+ /** 写类操作(write/remove)的 realpath 复核:allowAbs 是纯字符串判定,
2654
+ * 授权目录里的符号链接/junction 能把写入/递归删除引到授权范围之外。
2655
+ * 取目标最近已存在祖先的 realpath,要求它仍落在(工作区或该插件任一
2656
+ * 已授权目录)的 realpath 内。读/list/stat 保持字符串校验(高频路径,
2657
+ * 且读不存在「把内容写到别处」的风险)。 */
2658
+ const assertRealInsideGrant = async (abs) => {
2659
+ const targetReal = realPathOfNearest(abs);
2660
+ if (!targetReal)
2661
+ throw new Error(`无法解析真实路径:${abs}`);
2662
+ const granted = self.grants.list().find((g) => g.pluginId === info.id)?.paths ?? [];
2663
+ for (const r of [self.cwdValue, ...granted]) {
2664
+ const rootReal = realPathOfNearest(resolve(r)) ?? resolve(r);
2665
+ if (isInsideRoot(rootReal, targetReal))
2666
+ return;
2667
+ }
2668
+ throw new Error(`路径越界(符号链接指向授权范围之外):${abs}`);
2669
+ };
2599
2670
  const crossDirFs = {
2600
2671
  list: async (absDir) => {
2601
2672
  const abs = allowAbs(absDir);
@@ -2610,11 +2681,13 @@ export class PluginManager {
2610
2681
  },
2611
2682
  write: async (absPath, data) => {
2612
2683
  const abs = allowAbs(absPath);
2684
+ await assertRealInsideGrant(abs);
2613
2685
  await mkdir(dirname(abs), { recursive: true });
2614
2686
  await writeFile(abs, data);
2615
2687
  },
2616
2688
  remove: async (absPath) => {
2617
2689
  const abs = allowAbs(absPath);
2690
+ await assertRealInsideGrant(abs);
2618
2691
  await rm(abs, { recursive: true, force: true });
2619
2692
  },
2620
2693
  stat: async (absPath) => {
@@ -3208,8 +3281,23 @@ export class PluginManager {
3208
3281
  self.pushRuntimeDiag(info.id, `ui.update: unknown id "${String(id).slice(0, 32)}", ignored`);
3209
3282
  return;
3210
3283
  }
3211
- const merged = { ...base, ...patch, id, slot: base.slot };
3212
- self.uiRuntimeFor(info.id).items.set(id, merged);
3284
+ // 与注册同一条校验管线:patch 合进 base 后整体过 parseUiItem,
3285
+ // 非法字段按 parse 语义丢弃/回落(未知键清掉、iconSvg 重新归一化、
3286
+ // 超长截断、progress 夹取),id 与 slot 不可被 patch 改写。
3287
+ const diags = [];
3288
+ const mergedRaw = typeof patch === "object" && patch !== null && !Array.isArray(patch)
3289
+ ? { ...base, ...patch, id, slot: base.slot }
3290
+ : { ...base, id, slot: base.slot };
3291
+ const parsed = parseUiItem(mergedRaw, base.slot, diags);
3292
+ for (const m of diags)
3293
+ self.pushRuntimeDiag(info.id, `ui.update: ${m}`);
3294
+ if (!parsed) {
3295
+ // 合并结果不合法(典型:patch 把 label 改成空)——按现有 parse 语义
3296
+ // 视为无效更新,保留原条目。
3297
+ self.pushRuntimeDiag(info.id, `ui.update: merged item invalid, update ignored`);
3298
+ return;
3299
+ }
3300
+ self.uiRuntimeFor(info.id).items.set(id, parsed);
3213
3301
  void self.pushToAll().catch(() => { });
3214
3302
  },
3215
3303
  remove: (id) => {
@@ -3548,36 +3636,13 @@ export class PluginManager {
3548
3636
  fetch: async (url, init) => {
3549
3637
  if (!can("net"))
3550
3638
  return { ok: false, error: '插件未声明能力 "net"(manifest.permissions)——请求被拒' };
3551
- try {
3552
- const u = new URL(String(url));
3553
- if (u.protocol !== "http:" && u.protocol !== "https:") {
3554
- return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
3555
- }
3639
+ return pluginNetFetch(url, init, {
3556
3640
  // 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。
3557
3641
  // 用户动态批准的主机(host.requestPermission)同样放行,免改 manifest 重装。
3558
- const hostname = u.hostname.toLowerCase();
3559
- const allowed = netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) ||
3560
- self.permGrants.has(info.id, "net", { host: hostname });
3561
- if (!allowed)
3562
- return {
3563
- ok: false,
3564
- error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
3565
- };
3566
- if (init?.body !== undefined && Buffer.byteLength(String(init.body), "utf8") > 1024 * 1024) {
3567
- return { ok: false, error: "net: body 超过 1MB 上限" };
3568
- }
3569
- const res = await globalThis.fetch(String(url), {
3570
- method: init?.method ?? "GET",
3571
- ...(init?.headers ? { headers: init.headers } : {}),
3572
- ...(init?.body !== undefined ? { body: init.body } : {}),
3573
- signal: AbortSignal.timeout(15_000),
3574
- });
3575
- const text = (await res.text()).slice(0, 512 * 1024);
3576
- return { ok: true, status: res.status, text };
3577
- }
3578
- catch (err) {
3579
- return { ok: false, error: err.message };
3580
- }
3642
+ hostAllowed: (hostname) => netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) ||
3643
+ self.permGrants.has(info.id, "net", { host: hostname }),
3644
+ fetchImpl: (input, reqInit) => globalThis.fetch(input, reqInit),
3645
+ });
3581
3646
  },
3582
3647
  },
3583
3648
  events: {
@@ -3805,3 +3870,99 @@ export function syncPluginToolsIntoSession(session, defs, prevNames) {
3805
3870
  session._refreshToolRegistry();
3806
3871
  return new Set(defs.map((d) => d.name));
3807
3872
  }
3873
+ /** net.fetch 的手动重定向跳数上限。 */
3874
+ const NET_FETCH_MAX_REDIRECTS = 5;
3875
+ /**
3876
+ * net.fetch 的白名单 + 手动重定向循环(抽出为纯可注入函数,vitest 直测)。
3877
+ *
3878
+ * 为什么不用 redirect:"follow":跟随重定向不复查白名单,会把「只授权了 A 主机」
3879
+ * 变成「A 主机一跳把你带到任意主机/内网地址」(经典白名单绕过跳板)。所以:
3880
+ * - 每一跳(含首次)的目标 host 都重新过 allowHost(manifest 白名单 + 用户动态批准);
3881
+ * - redirect:"manual",至多 NET_FETCH_MAX_REDIRECTS 跳;
3882
+ * - 跨宿主重定向剥掉 authorization/cookie 头(浏览器同款语义,防凭据外带);
3883
+ * - 303 一律转 GET 并丢 body;其余 3xx 保持原 method/body(307/308 语义);
3884
+ * - 响应文本整体回给插件的现状保留(512KB 截断不变);
3885
+ * - timeout 是整条请求链(含所有跳)共享的墙钟预算(与原实现 15s 同量级)。
3886
+ */
3887
+ export async function pluginNetFetch(url, init, deps) {
3888
+ const deadline = Date.now() + Math.max(1000, Number(deps.timeoutMs ?? 15_000));
3889
+ let current = String(url ?? "");
3890
+ let method = init?.method;
3891
+ let headers = init?.headers;
3892
+ let body = init?.body;
3893
+ for (let hop = 0;; hop++) {
3894
+ let u;
3895
+ try {
3896
+ u = new URL(current);
3897
+ }
3898
+ catch {
3899
+ return { ok: false, error: `net: 无效 URL ${current}` };
3900
+ }
3901
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
3902
+ return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
3903
+ }
3904
+ // 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。每一跳都查。
3905
+ if (!deps.hostAllowed(u.hostname.toLowerCase())) {
3906
+ return {
3907
+ ok: false,
3908
+ error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
3909
+ };
3910
+ }
3911
+ if (body !== undefined && Buffer.byteLength(String(body), "utf8") > 1024 * 1024) {
3912
+ return { ok: false, error: "net: body 超过 1MB 上限" };
3913
+ }
3914
+ let res;
3915
+ try {
3916
+ res = await deps.fetchImpl(current, {
3917
+ ...(method ? { method } : {}),
3918
+ ...(headers ? { headers } : {}),
3919
+ ...(body !== undefined ? { body } : {}),
3920
+ redirect: "manual",
3921
+ signal: AbortSignal.timeout(Math.max(1000, deadline - Date.now())),
3922
+ });
3923
+ }
3924
+ catch (err) {
3925
+ return { ok: false, error: err.message };
3926
+ }
3927
+ // 3xx 且带 location → 手动跳下一跳(目标 host 重新过上面的白名单)。
3928
+ if (res.status >= 300 && res.status < 400) {
3929
+ const loc = res.headers.get("location");
3930
+ if (loc) {
3931
+ if (hop >= NET_FETCH_MAX_REDIRECTS) {
3932
+ return { ok: false, error: `net: 重定向超过 ${NET_FETCH_MAX_REDIRECTS} 跳上限` };
3933
+ }
3934
+ let next;
3935
+ try {
3936
+ next = new URL(loc, u);
3937
+ }
3938
+ catch {
3939
+ return { ok: false, error: `net: 重定向目标无效 ${loc}` };
3940
+ }
3941
+ if (next.hostname.toLowerCase() !== u.hostname.toLowerCase() && headers) {
3942
+ // 跨宿主:凭据类头不外带。
3943
+ const stripped = {};
3944
+ for (const [k, v] of Object.entries(headers)) {
3945
+ const key = k.toLowerCase();
3946
+ if (key === "authorization" || key === "cookie")
3947
+ continue;
3948
+ stripped[k] = v;
3949
+ }
3950
+ headers = stripped;
3951
+ }
3952
+ if (res.status === 303) {
3953
+ method = "GET";
3954
+ body = undefined;
3955
+ }
3956
+ current = next.toString();
3957
+ continue;
3958
+ }
3959
+ }
3960
+ try {
3961
+ const text = (await res.text()).slice(0, 512 * 1024);
3962
+ return { ok: true, status: res.status, text };
3963
+ }
3964
+ catch (err) {
3965
+ return { ok: false, error: err.message };
3966
+ }
3967
+ }
3968
+ }
@@ -19,7 +19,7 @@
19
19
  * resolvePathForDirCheck);回传的 `path` 是线形绝对路径("C:/…" / "/…"),
20
20
  * 前端拿它直接打 /api/file、file_reveal、file_open_default,不受会话 cwd 影响。
21
21
  *
22
- * 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
22
+ * 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
23
23
  * 结果文本走 pick(lang, zh, en, key, vars),缺表回落英文内联。
24
24
  *
25
25
  * DSH 引擎无 customTool 注册面(工具来自 shipped preset),本工具只服务 pi 引擎。
@@ -28,7 +28,7 @@ import { open, stat } from "node:fs/promises";
28
28
  import { basename, extname, sep } from "node:path";
29
29
  import { defineTool } from "@earendil-works/pi-coding-agent";
30
30
  import { Type } from "typebox";
31
- import { bilingual, pick } from "./i18n.js";
31
+ import { pick } from "./i18n.js";
32
32
  import { resolvePathForDirCheck } from "./read-tool.js";
33
33
  import { decodeText, isAudioFile, looksLikeText, previewKind } from "./text-sniff.js";
34
34
  import { PRESENT_FILES_TOOL_NAME } from "./tool-manager.js";
@@ -239,17 +239,14 @@ export function makePresentFilesTool(fallbackCwd, options = {}) {
239
239
  return defineTool({
240
240
  name: PRESENT_FILES_TOOL_NAME,
241
241
  label: "Show files to the user",
242
- description: bilingual("Show files to the user as preview cards in the chat. Each item renders as a card: images/videos/audio are displayed inline, text/markdown/HTML can be opened in the preview dialog, and every card carries buttons to open the file locally, reveal it in the file manager, download it or copy its path. " +
243
- "Use it whenever the user should LOOK at an artifact you produced or changed: a screenshot, chart, diagram, generated video/audio, report, log, build output. " +
244
- "Give workspace-relative paths (or absolute ones); up to 12 items per call; `title` and `note` are shown above the cards, `caption` under the file name, and `focus: true` makes the client open that item in the preview dialog right away. " +
245
- "Do not use it for files you merely read while reasoning, and do not repeat the file contents in your reply afterwards.", "把文件作为预览卡片展示给用户。每个条目渲染成一张卡片:图片/视频/音频直接在对话里显示,文本/markdown/HTML 可一键打开预览弹窗,每张卡片都带「本地打开 / 在文件管理器中显示 / 下载 / 复制路径」按钮。" +
246
- "适合用户**应该看一眼**的产物:截图、图表、示意图、生成的视频音频、报告、日志、构建产物。" +
247
- "路径写工作区相对路径(或绝对路径);单次最多 12 条;`title`/`note` 显示在卡片上方,`caption` 显示在文件名旁,`focus: true` 让客户端立刻用预览弹窗打开该条目。" +
248
- "只是自己读文件来推理时不要调用它,调用后也不要在回复里把文件内容再贴一遍。"),
249
- promptSnippet: bilingual("show images/videos/text files to the user as preview cards", "把图片/视频/文本文件作为预览卡片展示给用户"),
242
+ description: "Show files to the user as preview cards in the chat: images/videos/audio inline, text/markdown/HTML in a preview dialog; each card can open locally, reveal in file manager, download, or copy path. " +
243
+ "Use whenever the user should LOOK at an artifact you produced or changed (screenshot, chart, diagram, video/audio, report, log, build output). " +
244
+ "Give workspace-relative paths (max 12 items); `title`/`note` show above the cards, `caption` next to the file name, `focus: true` opens that item in the preview dialog immediately. " +
245
+ "Do not use for files you merely read while reasoning, and do not repeat file contents in your reply.",
246
+ promptSnippet: "show images/videos/text files to the user as preview cards",
250
247
  promptGuidelines: [
251
- bilingual("After producing something visual or user-facing (screenshot, chart, video, report, log, build output), call present_files so the user can actually see it instead of only printing the path", "产出可视化或面向用户的文件后(截图、图表、视频、报告、日志、构建产物),调 present_files 让用户真的看到,而不是只打印一行路径"),
252
- bilingual("Do not call present_files for ordinary source edits the user did not ask to see, and never call it twice for the same file in one turn", "用户没要求看的普通源码改动不要用 present_files;同一轮里不要为同一个文件调两次"),
248
+ "After producing something visual or user-facing (screenshot, chart, video, report, log, build output), call present_files so the user can actually see it instead of only printing the path",
249
+ "Do not call present_files for ordinary source edits the user did not ask to see, and never call it twice for the same file in one turn",
253
250
  ],
254
251
  parameters: Type.Object({
255
252
  title: Type.Optional(Type.String({
@@ -92,6 +92,10 @@ export async function snapshotProcessParents() {
92
92
  * asynchronous. Existing callers may continue to ignore it for best-effort
93
93
  * background cleanup; callers that own a deadline can await completion. */
94
94
  export async function killPidTree(pid) {
95
+ // pid 下界防护:pid<=1(init / Windows System 级 pid)或自身 pid 绝不能杀 ——
96
+ // POSIX 的 -pid 是进程组语义,误传 0/1/自身 pid 会杀掉整组无辜进程甚至本进程。
97
+ if (!(pid > 1 && pid !== process.pid))
98
+ return;
95
99
  try {
96
100
  if (process.platform === "win32") {
97
101
  const { spawn } = await import("node:child_process");
@@ -249,3 +249,12 @@ export function renderPromptTemplate(template, texts, overrides) {
249
249
  export function renderDefaultPrompt(texts) {
250
250
  return renderPromptTemplate(DEFAULT_PROMPT_TEMPLATE, texts, undefined);
251
251
  }
252
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/gu;
253
+ /** 粗略 token 估算(设置面板预览用,非精确分词,无 tokenizer 依赖):
254
+ * CJK 字符(汉字/假名/谚文)≈ 1 token/字,其余字符 ≈ 1 token / 4 字符。 */
255
+ export function estimatePromptTokens(text) {
256
+ if (!text)
257
+ return 0;
258
+ const cjk = text.match(CJK_RE)?.length ?? 0;
259
+ return Math.ceil(cjk + (text.length - cjk) / 4);
260
+ }
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 19;
11
+ export const PROTOCOL_VERSION = 20;