pi-web-ui 0.97.0 → 0.99.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.
- package/CHANGELOG.md +126 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +645 -147
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +30 -5
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +77 -10
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -26
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +2 -3
- package/dist/server/patch-tool.js +78 -28
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +196 -10
- package/dist/server/plan-mode.js +58 -20
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +3 -5
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +16 -2
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +24 -12
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -40
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
- package/web/dist/assets/index-BdMaGCcs.css +1 -0
- package/web/dist/assets/index-ByCBN4Kt.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
package/dist/server/plan-mode.js
CHANGED
|
@@ -46,6 +46,9 @@ const BLOCKED_TOOLS = new Set([
|
|
|
46
46
|
"rename",
|
|
47
47
|
// 版本控制写(git add/commit/checkout/reset…;git 的只读子命令走 bash 白名单)
|
|
48
48
|
"git",
|
|
49
|
+
// 代码求值(eval 沙箱的 execute 直接 kernel.execute,可写真实文件系统;
|
|
50
|
+
// 默认关但用户可开,开了就是一条写路径 —— #436)
|
|
51
|
+
"eval",
|
|
49
52
|
// 形态变换(会重写整个文件树)
|
|
50
53
|
"format",
|
|
51
54
|
"prettier",
|
|
@@ -53,25 +56,43 @@ const BLOCKED_TOOLS = new Set([
|
|
|
53
56
|
"autofix",
|
|
54
57
|
]);
|
|
55
58
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* 常驻终端的「写向」工具(server/terminals.ts):直接往 PTY stdin 写字符/按键。
|
|
60
|
+
* 必须整体剥离而不只是补检查:terminal_input 只在自带换行时才过 checkSafety,
|
|
61
|
+
* terminal_key 的 Enter 连检查都没有 —— 「input(无换行) + key(Enter)」两步即可
|
|
62
|
+
* 绕过 bash 只读白名单执行任意命令(#436)。且 PTY 的行编辑缓冲在 shell 进程内,
|
|
63
|
+
* 服务端拿不到可靠的「待提交行」(退格/Ctrl+U/补全都无法镜像),按缓冲检查既会
|
|
64
|
+
* 误放也会误拦 —— 所以计划模式下干脆不给这两件工具:调研用 bash(走白名单),
|
|
65
|
+
* 观察已有终端用 terminal_read/terminal_list/terminal_wait(只读)。
|
|
59
66
|
*/
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
const TERMINAL_WRITE_TOOLS = new Set(["terminal_input", "terminal_key"]);
|
|
68
|
+
/**
|
|
69
|
+
* 三个只读闸门(计划 / 审查者 / 目标审查)共用的「派发类」工具名:它们会把活
|
|
70
|
+
* 派到**别的会话**执行(delegate_task 走子代理 spawn 通道;schedule_task 到期
|
|
71
|
+
* 唤醒对话跑 prompt),而这三道闸门都是会话级的 —— 放行即绕过。
|
|
72
|
+
*
|
|
73
|
+
* 名单以 server/tool-manager.ts 实际注册的工具名为事实源(#436):历史名单里的
|
|
74
|
+
* spawn / spawn_agent / subagent_spawn / schedule_agent / host_schedule /
|
|
75
|
+
* create_goal / set_goal / start_goal_wizard / create_conversation /
|
|
76
|
+
* fork_conversation / schedule / set_plan_mode 等都是协议消息名或从未注册的
|
|
77
|
+
* 幽灵名,一个真实工具都没拦住,反而掩盖了 schedule_task 这类真实旁路工具
|
|
78
|
+
* 完全可用的事实。subagent 是单 action 工具,由各闸门按 action 细分(只读
|
|
79
|
+
* action 放行,见各闸门的判定函数)。
|
|
80
|
+
*/
|
|
81
|
+
export const SESSION_DISPATCH_TOOLS = new Set(["delegate_task", "schedule_task"]);
|
|
82
|
+
/**
|
|
83
|
+
* 计划模式下也禁掉的「旁路工具」(真实派发类,见 SESSION_DISPATCH_TOOLS):
|
|
84
|
+
* 它们能在**别的会话**里干活,而闸门是会话级的 —— 子代理/排程对话的
|
|
85
|
+
* planMode 是 false,等于绕过只读约束。调研靠本对话的 read/grep/只读 bash
|
|
86
|
+
* 足够,需要并行调研时先退出计划模式。
|
|
87
|
+
*/
|
|
88
|
+
const BYPASS_TOOLS = SESSION_DISPATCH_TOOLS;
|
|
73
89
|
/** 计划模式下从模型视野中彻底隐藏的写类工具与旁路工具全集。 */
|
|
74
|
-
export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
|
|
90
|
+
export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
|
|
91
|
+
...BLOCKED_TOOLS,
|
|
92
|
+
...BYPASS_TOOLS,
|
|
93
|
+
...TERMINAL_WRITE_TOOLS,
|
|
94
|
+
"subagent",
|
|
95
|
+
]);
|
|
75
96
|
/** bash 类工具名(terminalBash 开关分流后可能是这几个)。 */
|
|
76
97
|
const BASH_TOOLS = new Set(["bash", "bash_execute", "run_command", "shell", "terminal", "terminal_exec"]);
|
|
77
98
|
/** 计划模式放行的只读命令(basename,无参数)。刻意保守:宁可拒一次让模型换命令。 */
|
|
@@ -238,6 +259,10 @@ function segmentIsReadOnly(segment) {
|
|
|
238
259
|
return false;
|
|
239
260
|
if (name === "awk" && /system\s*\(/.test(rest))
|
|
240
261
|
return false;
|
|
262
|
+
// find 的 -exec/-delete/-fprintf 族有写副作用(issue #483):白名单命中不等于只读,
|
|
263
|
+
// `find . -name '*.log' -delete` / `-exec touch x {} +` 无任何重定向特征,必须显式拒。
|
|
264
|
+
if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint0|fprint|fprintf|fls)$/.test(a)))
|
|
265
|
+
return false;
|
|
241
266
|
// 解释器:只放行 --version/--help 这类无副作用调用。
|
|
242
267
|
if (name === "node" || name === "python" || name === "python3" || name === "npm") {
|
|
243
268
|
return args.length > 0 && args.every((a) => INTERPRETER_SAFE_ARGS.has(a));
|
|
@@ -274,6 +299,7 @@ function commandOf(params) {
|
|
|
274
299
|
* 计划模式闸门(纯函数):命中即拒绝,并把可操作的替代路径告诉模型。
|
|
275
300
|
* - 写类工具 → 拒(提示:把要做的事写进计划,用户确认后另起一轮实施)
|
|
276
301
|
* - bash 非常规命令 → 拒(提示:用 read/grep/只读 git,或给出计划)
|
|
302
|
+
* - 终端写向工具(terminal_input/terminal_key)→ 拒(会绕过只读命令白名单)
|
|
277
303
|
* - 旁路工具(spawn/delegate/目标模式/排程)→ 拒(会绕过本会话只读约束)
|
|
278
304
|
*/
|
|
279
305
|
export function planModeDenial(toolName, params) {
|
|
@@ -283,14 +309,26 @@ export function planModeDenial(toolName, params) {
|
|
|
283
309
|
if (name === "subagent") {
|
|
284
310
|
const action = typeof params === "object" && params !== null ? params.action : undefined;
|
|
285
311
|
const act = typeof action === "string" ? action.trim().toLowerCase() : "";
|
|
286
|
-
|
|
312
|
+
// 与 goal-review-gate 同口径:只读 action(get_result/list/templates,看状态
|
|
313
|
+
// 与名录)放行;其余(spawn/steer/stop/wait_all/handoff,见 subagents.ts 的
|
|
314
|
+
// SUBAGENT_ACTIONS)或未传 action 一律拒 —— steer/handoff 会向运行中的子代理
|
|
315
|
+
// 会话投指令驱动它继续写代码,子代理会话 planMode=false 工具齐全,等于绕过
|
|
316
|
+
// 本会话的只读约束(#436 此前只拒了 spawn)。
|
|
317
|
+
if (act !== "get_result" && act !== "list" && act !== "templates") {
|
|
287
318
|
return {
|
|
288
319
|
kind: "bypass-tool",
|
|
289
|
-
reason: `计划模式:不能调用 subagent(action="spawn")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
|
|
290
|
-
reasonEn: `Plan mode: subagent(action="spawn") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
|
|
320
|
+
reason: `计划模式:不能调用 subagent(action="${act || "spawn"}")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
|
|
321
|
+
reasonEn: `Plan mode: subagent(action="${act || "spawn"}") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
|
|
291
322
|
};
|
|
292
323
|
}
|
|
293
324
|
}
|
|
325
|
+
if (TERMINAL_WRITE_TOOLS.has(name)) {
|
|
326
|
+
return {
|
|
327
|
+
kind: "write-tool",
|
|
328
|
+
reason: `计划模式:不能调用 ${name}(终端输入/按键会绕过只读命令白名单,见 #436)。调研请用 bash 跑只读命令;观察已有终端用 terminal_read/terminal_list/terminal_wait。`,
|
|
329
|
+
reasonEn: `Plan mode: ${name} is unavailable (terminal input/keys would bypass the read-only command allowlist). Run read-only commands via bash; observe existing terminals with terminal_read/terminal_list/terminal_wait.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
294
332
|
if (BYPASS_TOOLS.has(name)) {
|
|
295
333
|
return {
|
|
296
334
|
kind: "bypass-tool",
|
|
@@ -23,6 +23,8 @@ export const SLOT_EXAMPLES = {
|
|
|
23
23
|
"goalbar.actions": `{ "ui": { "goalbar.actions": [{ "id": "m", "label": "目标条", "kind": "action", "action": "my:m" }] } }`,
|
|
24
24
|
"notice.actions": `{ "ui": { "notice.actions": [{ "id": "m", "label": "通知动作", "kind": "action", "action": "my:m" }] } }`,
|
|
25
25
|
"modal.dialog": `{ "modal": [{ "id": "m", "label": "弹窗", "kind": "action", "action": "my:m" }] }`,
|
|
26
|
+
"sidebar.left": `{ "sidebar.left": [{ "id": "m", "label": "左侧悬浮", "kind": "action", "action": "my:m" }] }`,
|
|
27
|
+
"sidebar.right": `{ "sidebar.right": [{ "id": "m", "label": "右侧悬浮", "kind": "action", "action": "my:m" }] }`,
|
|
26
28
|
};
|
|
27
29
|
/** 宿主方法表(与 server/plugins.ts#PluginHost 同语义的精简版:只收发现用的注册/调用面)。
|
|
28
30
|
* needs="-" = 观察/基础设施类(无需能力声明);其余走 can() 门控。 */
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 设计上服务端不做网络探测(不拉 manifest):名称/简介/图标由条目本身携带
|
|
15
15
|
* (作者填),id 由来源推导或作者显式指定 —— 保持离线可用、可单测。
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync,
|
|
18
|
-
import {
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
19
19
|
import { normalizeIconSvg } from "./icon-svg.js";
|
|
20
20
|
import { pick } from "./i18n.js";
|
|
21
21
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
@@ -61,10 +61,7 @@ function readJsonSafe(path, fallback) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function atomicWrite(path, data) {
|
|
64
|
-
|
|
65
|
-
const tmp = `${path}.tmp-${process.pid}`;
|
|
66
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
67
|
-
renameSync(tmp, path);
|
|
64
|
+
writeJsonAtomicSync(path, data);
|
|
68
65
|
}
|
|
69
66
|
/** 把任意对象规范化为合法条目;非法则返回 null。 */
|
|
70
67
|
function toEntry(raw, builtin) {
|
|
@@ -75,6 +72,7 @@ function toEntry(raw, builtin) {
|
|
|
75
72
|
if (!ID_RE.test(id))
|
|
76
73
|
return null;
|
|
77
74
|
const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : id;
|
|
75
|
+
const nameEn = typeof raw.nameEn === "string" && raw.nameEn.trim() ? raw.nameEn.trim() : undefined;
|
|
78
76
|
const description = typeof raw.description === "string" && raw.description.trim() ? raw.description.trim() : undefined;
|
|
79
77
|
const descriptionEn = typeof raw.descriptionEn === "string" && raw.descriptionEn.trim() ? raw.descriptionEn.trim() : undefined;
|
|
80
78
|
const icon = typeof raw.icon === "string" && raw.icon.trim() ? raw.icon.trim() : undefined;
|
|
@@ -85,6 +83,7 @@ function toEntry(raw, builtin) {
|
|
|
85
83
|
name,
|
|
86
84
|
source,
|
|
87
85
|
builtin,
|
|
86
|
+
...(nameEn ? { nameEn } : {}),
|
|
88
87
|
...(description ? { description } : {}),
|
|
89
88
|
...(descriptionEn ? { descriptionEn } : {}),
|
|
90
89
|
...(icon ? { icon } : {}),
|
|
@@ -231,6 +230,7 @@ export function writeCustomCatalog(customPath, incoming, replace) {
|
|
|
231
230
|
id: e.id,
|
|
232
231
|
source: e.source,
|
|
233
232
|
name: e.name,
|
|
233
|
+
...(e.nameEn ? { nameEn: e.nameEn } : {}),
|
|
234
234
|
...(e.description ? { description: e.description } : {}),
|
|
235
235
|
...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
|
|
236
236
|
...(e.icon ? { icon: e.icon } : {}),
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
|
|
14
14
|
* (内存态仍生效,本次会话可用)。
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
18
19
|
const FILE = "plugin-dom.json";
|
|
19
20
|
/** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
|
|
20
21
|
* 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
|
|
@@ -71,9 +72,7 @@ export class PluginDomConsent {
|
|
|
71
72
|
else
|
|
72
73
|
this.granted.delete(id);
|
|
73
74
|
try {
|
|
74
|
-
|
|
75
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
|
|
76
|
-
renameSync(tmp, join(this.dataDir, FILE));
|
|
75
|
+
writeJsonAtomicSync(join(this.dataDir, FILE), { v: 1, granted: this.list() });
|
|
77
76
|
}
|
|
78
77
|
catch {
|
|
79
78
|
/* 写失败 best-effort:内存态仍生效,本次会话可用 */
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
17
|
*/
|
|
18
18
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
-
import { chmodSync, existsSync,
|
|
19
|
+
import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
20
20
|
import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
|
|
21
21
|
import { dirname, join, relative, resolve } from "node:path";
|
|
22
|
+
import { writeAtomicSync } from "./atomic-file.js";
|
|
22
23
|
import { createRequire } from "node:module";
|
|
23
24
|
import { spawnSync } from "node:child_process";
|
|
24
25
|
// 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
|
|
@@ -26,10 +27,7 @@ import { spawnSync } from "node:child_process";
|
|
|
26
27
|
import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
|
|
27
28
|
/** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
|
|
28
29
|
function atomicWrite(file, data) {
|
|
29
|
-
|
|
30
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
31
|
-
writeFileSync(tmp, data);
|
|
32
|
-
renameSync(tmp, file);
|
|
30
|
+
writeAtomicSync(file, data);
|
|
33
31
|
}
|
|
34
32
|
// ---------------------------------------------------------------------------
|
|
35
33
|
// storage.json 的进程内「读-改-写」互斥
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
* 与尾部分隔符(Windows 文件系统不区分大小写),但**存储保持用户写入时的形式**
|
|
37
37
|
* (不 lowerCase),否则设置面板里 `C:\Users\Foo` 会显示成 `c:\users\foo`。
|
|
38
38
|
*/
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
41
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
41
42
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
42
43
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
43
44
|
/** 去掉尾部分隔符(保留根:「C:\」/「/」不能退化)。 */
|
|
@@ -93,13 +94,9 @@ function isSameOrInside(targetKey, ancestorKey) {
|
|
|
93
94
|
const prefix = ancestorKey.endsWith(sep) ? ancestorKey : ancestorKey + sep;
|
|
94
95
|
return targetKey.startsWith(prefix);
|
|
95
96
|
}
|
|
96
|
-
/** 原子写:临时文件 + rename
|
|
97
|
-
* 它,避免为一个 3 行函数制造模块耦合)。 */
|
|
97
|
+
/** 原子写:临时文件 + rename。 */
|
|
98
98
|
function atomicWriteJson(filePath, data) {
|
|
99
|
-
|
|
100
|
-
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
101
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
102
|
-
renameSync(tmp, filePath);
|
|
99
|
+
writeJsonAtomicSync(filePath, data);
|
|
103
100
|
}
|
|
104
101
|
/** 读文件并净化为内存表:任何异常(不存在 / 权限 / JSON 坏 / 形状不对)→ 空表。
|
|
105
102
|
* 条目逐个过 normalizeGrantPath(顺手兼容手工写入的相对路径、重复项、坏 id)。 */
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - 内存 session 授权(仅本次运行,进程结束即失)。
|
|
14
14
|
* 读坏/形状不对当空表,且不在读路径回写;只有真正变更的写才落盘(tmp+rename 原子写)。
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync
|
|
17
|
-
import {
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
18
19
|
/** 主机匹配(与 manifest netAllowlist 同口径):全等或点号后缀。纯函数,单测覆盖。 */
|
|
19
20
|
export function permissionHostMatches(hostname, entry) {
|
|
20
21
|
const h = String(hostname ?? "").toLowerCase();
|
|
@@ -63,10 +64,7 @@ export class PluginPermissionStore {
|
|
|
63
64
|
}
|
|
64
65
|
write(grants) {
|
|
65
66
|
try {
|
|
66
|
-
|
|
67
|
-
const tmp = `${this.file()}.tmp-${process.pid}`;
|
|
68
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, grants }));
|
|
69
|
-
renameSync(tmp, this.file());
|
|
67
|
+
writeJsonAtomicSync(this.file(), { v: 1, grants });
|
|
70
68
|
}
|
|
71
69
|
catch (err) {
|
|
72
70
|
console.error(`[plugin-permissions] persist failed (${this.file()}):`, err);
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 上次触发 lastRun;fn 回调永远由插件代码在 activate 里重给(重启后重建)。
|
|
11
11
|
* 坏文件当空表(不回写,避免一次磁盘抖动清空全部定时)。
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync
|
|
14
|
-
import {
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
15
16
|
const MONTH_NAMES = {
|
|
16
17
|
jan: 1,
|
|
17
18
|
feb: 2,
|
|
@@ -224,11 +225,7 @@ export function loadScheduleRecords(pluginDir) {
|
|
|
224
225
|
/** 写持久记录(tmp+rename 原子写;失败只记日志,内存态本次会话仍生效)。 */
|
|
225
226
|
export function saveScheduleRecords(pluginDir, records) {
|
|
226
227
|
try {
|
|
227
|
-
|
|
228
|
-
const file = scheduleFile(pluginDir);
|
|
229
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
230
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, schedules: records }));
|
|
231
|
-
renameSync(tmp, file);
|
|
228
|
+
writeJsonAtomicSync(scheduleFile(pluginDir), { v: 1, schedules: records });
|
|
232
229
|
}
|
|
233
230
|
catch (err) {
|
|
234
231
|
console.error(`[plugin-schedule] persist failed (${pluginDir}):`, err);
|
|
@@ -8,13 +8,29 @@
|
|
|
8
8
|
* <dataDir>/plugin-backups/<id>-<ts>/ 覆盖安装前的旧版本快照(保留最近 N 份)
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
11
|
+
import { join, resolve, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { execFile } from "node:child_process";
|
|
13
14
|
import { pick } from "./i18n.js";
|
|
14
15
|
import { parseInstallSpec, manifestCandidateUrls } from "./plugin-install-spec.js";
|
|
15
16
|
const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
16
17
|
/** 保留的备份份数(超出删除最旧的)。 */
|
|
17
18
|
export const BACKUP_KEEP = 3;
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
/**
|
|
21
|
+
* 解析宿主随包根目录(优先环境变量 PI_WEB_PKG_ROOT,次之向上查找包含 package.json 的祖先目录)。
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDefaultPkgRoot() {
|
|
24
|
+
if (process.env.PI_WEB_PKG_ROOT && existsSync(process.env.PI_WEB_PKG_ROOT)) {
|
|
25
|
+
return process.env.PI_WEB_PKG_ROOT;
|
|
26
|
+
}
|
|
27
|
+
const candidates = [resolve(here, ".."), resolve(here, "..", ".."), resolve(here, "..", "..", "..")];
|
|
28
|
+
for (const c of candidates) {
|
|
29
|
+
if (existsSync(join(c, "package.json")))
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
18
34
|
/** 默认执行器:execFile 直跑 git(不经 shell),15s 超时。 */
|
|
19
35
|
export const execGit = (cmd, args) => new Promise((resolve) => {
|
|
20
36
|
execFile(cmd, args, { timeout: 15_000, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
@@ -151,25 +167,98 @@ export async function resolveRemoteSha(spec, exec = execGit) {
|
|
|
151
167
|
const sha = res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1] ?? null;
|
|
152
168
|
return sha ? sha.slice(0, 12) : null;
|
|
153
169
|
}
|
|
154
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* 语义化版本(SemVer)比较器:
|
|
172
|
+
* - 剥离前缀 `v` / `V` 及首尾空白;
|
|
173
|
+
* - 剥离 `+` 后的构建元数据(build metadata 在优先级比较中被忽略);
|
|
174
|
+
* - 分离主要数字版本与预发布标识(prerelease,如 -beta.1);
|
|
175
|
+
* - 支持任意段数的数字比对;
|
|
176
|
+
* - 符合 SemVer 规范:正式版 > 预发布版(例如 1.0.0 > 1.0.0-beta.1);
|
|
177
|
+
* - 返回值:>0 代表 a 比 b 新;<0 代表 a 比 b 旧;0 代表版本一致。
|
|
178
|
+
*/
|
|
155
179
|
export function compareVersions(a, b) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
const clean = (s) => String(s ?? "")
|
|
181
|
+
.trim()
|
|
182
|
+
.replace(/^[vV]/, "")
|
|
183
|
+
.split("+")[0]
|
|
184
|
+
.trim();
|
|
185
|
+
const cleanA = clean(a);
|
|
186
|
+
const cleanB = clean(b);
|
|
187
|
+
if (cleanA === cleanB)
|
|
188
|
+
return 0;
|
|
189
|
+
if (!cleanA && !cleanB)
|
|
190
|
+
return 0;
|
|
191
|
+
if (!cleanA)
|
|
192
|
+
return -1;
|
|
193
|
+
if (!cleanB)
|
|
194
|
+
return 1;
|
|
195
|
+
// 拆分主版本段与 prerelease 段
|
|
196
|
+
const dashA = cleanA.indexOf("-");
|
|
197
|
+
const mainAStr = dashA >= 0 ? cleanA.slice(0, dashA) : cleanA;
|
|
198
|
+
const preA = dashA >= 0 ? cleanA.slice(dashA + 1) : null;
|
|
199
|
+
const dashB = cleanB.indexOf("-");
|
|
200
|
+
const mainBStr = dashB >= 0 ? cleanB.slice(0, dashB) : cleanB;
|
|
201
|
+
const preB = dashB >= 0 ? cleanB.slice(dashB + 1) : null;
|
|
202
|
+
// 比较主版本数字段
|
|
203
|
+
const segsA = mainAStr.split(".").map((n) => parseInt(n, 10) || 0);
|
|
204
|
+
const segsB = mainBStr.split(".").map((n) => parseInt(n, 10) || 0);
|
|
205
|
+
const maxLen = Math.max(segsA.length, segsB.length);
|
|
206
|
+
for (let i = 0; i < maxLen; i++) {
|
|
207
|
+
const x = segsA[i] ?? 0;
|
|
208
|
+
const y = segsB[i] ?? 0;
|
|
161
209
|
if (x !== y)
|
|
162
210
|
return x - y;
|
|
163
211
|
}
|
|
212
|
+
// 主版本数字段全相同时,比较 prerelease(正式版高于任何 prerelease 版)
|
|
213
|
+
if (preA === null && preB !== null)
|
|
214
|
+
return 1;
|
|
215
|
+
if (preA !== null && preB === null)
|
|
216
|
+
return -1;
|
|
217
|
+
if (preA !== null && preB !== null) {
|
|
218
|
+
const partsA = preA.split(".");
|
|
219
|
+
const partsB = preB.split(".");
|
|
220
|
+
const maxPreLen = Math.max(partsA.length, partsB.length);
|
|
221
|
+
for (let i = 0; i < maxPreLen; i++) {
|
|
222
|
+
const pa = partsA[i];
|
|
223
|
+
const pb = partsB[i];
|
|
224
|
+
if (pa === undefined)
|
|
225
|
+
return -1;
|
|
226
|
+
if (pb === undefined)
|
|
227
|
+
return 1;
|
|
228
|
+
if (pa === pb)
|
|
229
|
+
continue;
|
|
230
|
+
const isNumA = /^\d+$/.test(pa);
|
|
231
|
+
const isNumB = /^\d+$/.test(pb);
|
|
232
|
+
if (isNumA && isNumB) {
|
|
233
|
+
const diff = parseInt(pa, 10) - parseInt(pb, 10);
|
|
234
|
+
if (diff !== 0)
|
|
235
|
+
return diff;
|
|
236
|
+
}
|
|
237
|
+
else if (isNumA && !isNumB) {
|
|
238
|
+
return -1; // 数字标识优先级低于非数字标识
|
|
239
|
+
}
|
|
240
|
+
else if (!isNumA && isNumB) {
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
return pa.localeCompare(pb);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
164
248
|
return 0;
|
|
165
249
|
}
|
|
166
250
|
/** 判断某个插件是否属于随包维护的内置插件(官方插件)。 */
|
|
167
251
|
export function isBuiltinPlugin(id, source, builtinCatalogPath) {
|
|
168
252
|
if (source && /xing-shuyin\/pi-web-ui\/plugins\//i.test(source))
|
|
169
253
|
return true;
|
|
170
|
-
|
|
254
|
+
const catPath = builtinCatalogPath ??
|
|
255
|
+
(() => {
|
|
256
|
+
const pkgRoot = resolveDefaultPkgRoot();
|
|
257
|
+
return pkgRoot ? join(pkgRoot, "plugins", "catalog.json") : undefined;
|
|
258
|
+
})();
|
|
259
|
+
if (catPath && existsSync(catPath)) {
|
|
171
260
|
try {
|
|
172
|
-
const raw = JSON.parse(readFileSync(
|
|
261
|
+
const raw = JSON.parse(readFileSync(catPath, "utf8"));
|
|
173
262
|
if (Array.isArray(raw)) {
|
|
174
263
|
return raw.some((e) => e?.id === id);
|
|
175
264
|
}
|
|
@@ -180,9 +269,23 @@ export function isBuiltinPlugin(id, source, builtinCatalogPath) {
|
|
|
180
269
|
}
|
|
181
270
|
return false;
|
|
182
271
|
}
|
|
183
|
-
/**
|
|
272
|
+
/** 探测远端或本地源 manifest 中的版本号(本地路径 / raw.githubusercontent.com / 注入的 fetcher)。 */
|
|
184
273
|
async function fetchRemoteVersion(source, fetcher) {
|
|
185
274
|
const spec = parseInstallSpec(source);
|
|
275
|
+
if (spec.kind === "path" && spec.normalized) {
|
|
276
|
+
const manifestFile = join(spec.normalized, "manifest.json");
|
|
277
|
+
if (existsSync(manifestFile)) {
|
|
278
|
+
try {
|
|
279
|
+
const data = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
280
|
+
if (typeof data?.version === "string" && data.version.trim()) {
|
|
281
|
+
return data.version.trim();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
186
289
|
const urls = manifestCandidateUrls(spec);
|
|
187
290
|
if (urls.length === 0)
|
|
188
291
|
return null;
|
|
@@ -212,6 +315,8 @@ export async function checkPluginUpdates(dataDir, exec = execGit,
|
|
|
212
315
|
/** error 字段文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
|
|
213
316
|
lang, opts) {
|
|
214
317
|
const l = lang?.() ?? "en";
|
|
318
|
+
const effectivePkgRoot = opts?.pkgRoot ?? resolveDefaultPkgRoot();
|
|
319
|
+
const effectiveCatalogPath = opts?.builtinCatalogPath ?? (effectivePkgRoot ? join(effectivePkgRoot, "plugins", "catalog.json") : undefined);
|
|
215
320
|
const pluginsDir = join(dataDir, "plugins");
|
|
216
321
|
let names = [];
|
|
217
322
|
try {
|
|
@@ -235,23 +340,27 @@ lang, opts) {
|
|
|
235
340
|
localSha = readFileSync(join(dir, ".pi-git-sha"), "utf8").trim() || null;
|
|
236
341
|
}
|
|
237
342
|
catch {
|
|
238
|
-
localSha = null; // 无 sha 记录
|
|
343
|
+
localSha = null; // 无 sha 记录
|
|
239
344
|
}
|
|
240
345
|
let name;
|
|
241
346
|
let version;
|
|
242
347
|
try {
|
|
243
348
|
const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
244
349
|
name = m.name;
|
|
245
|
-
version
|
|
350
|
+
if (typeof m.version === "string" && m.version.trim()) {
|
|
351
|
+
version = m.version.trim();
|
|
352
|
+
}
|
|
246
353
|
}
|
|
247
354
|
catch {
|
|
248
355
|
/* 坏 manifest:仍报告 */
|
|
249
356
|
}
|
|
250
|
-
const
|
|
357
|
+
const parsedSpec = parseInstallSpec(source);
|
|
358
|
+
const isSubpath = Boolean(parsedSpec.subpath);
|
|
359
|
+
const builtin = isBuiltinPlugin(n, source, effectiveCatalogPath);
|
|
251
360
|
let latestVersion = null;
|
|
252
|
-
//
|
|
253
|
-
if (
|
|
254
|
-
const localPkgManifest = join(
|
|
361
|
+
// 随包或本地开发模式:若宿主包自带 plugins/<id>/manifest.json,可直接读随包最新版本号
|
|
362
|
+
if (effectivePkgRoot) {
|
|
363
|
+
const localPkgManifest = join(effectivePkgRoot, "plugins", n, "manifest.json");
|
|
255
364
|
if (existsSync(localPkgManifest)) {
|
|
256
365
|
try {
|
|
257
366
|
const rawPkg = JSON.parse(readFileSync(localPkgManifest, "utf8"));
|
|
@@ -264,15 +373,18 @@ lang, opts) {
|
|
|
264
373
|
}
|
|
265
374
|
}
|
|
266
375
|
}
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
376
|
+
// 尝试通过 fetcher 探测远端 manifest.json,若拿到了更高版本则采用更高者
|
|
377
|
+
try {
|
|
378
|
+
const remoteVer = await fetchRemoteVersion(source, opts?.fetcher);
|
|
379
|
+
if (remoteVer) {
|
|
380
|
+
if (!latestVersion || compareVersions(remoteVer, latestVersion) > 0) {
|
|
381
|
+
latestVersion = remoteVer;
|
|
382
|
+
}
|
|
274
383
|
}
|
|
275
384
|
}
|
|
385
|
+
catch {
|
|
386
|
+
/* ignore */
|
|
387
|
+
}
|
|
276
388
|
let remoteSha = null;
|
|
277
389
|
let error;
|
|
278
390
|
try {
|
|
@@ -282,27 +394,28 @@ lang, opts) {
|
|
|
282
394
|
error = err instanceof Error ? err.message : String(err);
|
|
283
395
|
remoteSha = null;
|
|
284
396
|
}
|
|
285
|
-
if (!remoteSha && !latestVersion && !error)
|
|
286
|
-
error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
|
|
287
397
|
let updatable = false;
|
|
288
398
|
if (latestVersion && version) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
else if (cmp === 0 && remoteSha && localSha) {
|
|
294
|
-
updatable = localSha !== remoteSha;
|
|
295
|
-
}
|
|
296
|
-
else if (cmp === 0 && remoteSha && !localSha) {
|
|
297
|
-
updatable = true;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
else if (remoteSha) {
|
|
301
|
-
updatable = !localSha || localSha !== remoteSha;
|
|
399
|
+
// 两端都有版本号:版本号是唯一权威事实源。
|
|
400
|
+
// 严格大于才判定为有更新(cmp > 0)。
|
|
401
|
+
// 相同或更低(cmp <= 0)坚决判定为无更新,彻底避免主仓库无关 commit 触发的假阳性误报。
|
|
402
|
+
updatable = compareVersions(latestVersion, version) > 0;
|
|
302
403
|
}
|
|
303
404
|
else if (latestVersion && !version) {
|
|
405
|
+
// 远端有版本号但本地无版本号:视为可更新
|
|
304
406
|
updatable = true;
|
|
305
407
|
}
|
|
408
|
+
else if (!isSubpath && remoteSha) {
|
|
409
|
+
// 仅在未能获取最新版本号且为独立仓库源(非子目录)时,才回退到基于 git commit SHA 比对
|
|
410
|
+
updatable = !localSha || localSha !== remoteSha;
|
|
411
|
+
}
|
|
412
|
+
if (!updatable && !latestVersion && !remoteSha && !error) {
|
|
413
|
+
error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
|
|
414
|
+
}
|
|
415
|
+
else if (!updatable && isSubpath && !latestVersion && !error) {
|
|
416
|
+
// 子目录源插件未能获取远端清单时,不凭仓库根 SHA 误报,而是提示未能获取清单
|
|
417
|
+
error = pick(l, "无法检查(未能获取远端插件清单)", "Cannot check (failed to fetch remote plugin manifest)", "pluginupdate.subpath.manifest.failed");
|
|
418
|
+
}
|
|
306
419
|
out.push({
|
|
307
420
|
id: n,
|
|
308
421
|
name,
|
package/dist/server/plugins.js
CHANGED
|
@@ -275,6 +275,8 @@ export const UI_SLOTS = new Set([
|
|
|
275
275
|
"goalbar.actions",
|
|
276
276
|
"notice.actions",
|
|
277
277
|
"modal.dialog",
|
|
278
|
+
"sidebar.left",
|
|
279
|
+
"sidebar.right",
|
|
278
280
|
]);
|
|
279
281
|
/** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
|
|
280
282
|
export const UI_SLOT_ALIASES = {
|
|
@@ -285,6 +287,11 @@ export const UI_SLOT_ALIASES = {
|
|
|
285
287
|
rightpanel: "rightpanel.tabs",
|
|
286
288
|
settings: "settings.pages",
|
|
287
289
|
modal: "modal.dialog",
|
|
290
|
+
sidebar: "sidebar.left",
|
|
291
|
+
"sidebar.left": "sidebar.left",
|
|
292
|
+
"sidebar.right": "sidebar.right",
|
|
293
|
+
leftbar: "sidebar.left",
|
|
294
|
+
rightbar: "sidebar.right",
|
|
288
295
|
};
|
|
289
296
|
/** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
|
|
290
297
|
export const UI_KINDS = new Set([
|
|
@@ -1473,7 +1480,11 @@ export class PluginManager {
|
|
|
1473
1480
|
params: freezeParams(req.params),
|
|
1474
1481
|
conversationId: req.conversationId,
|
|
1475
1482
|
};
|
|
1476
|
-
|
|
1483
|
+
// #489:对 this.loaded 取快照再迭代——下面的 withGuardTimeout 是 await 点,
|
|
1484
|
+
// 期间插件 reload/uninstall 会原地 clear 同一个 Map(实例不变),Map 迭代器
|
|
1485
|
+
// 立即 done → 剩余插件的 pre 守卫一次都不被求值 → fail-open 直接放行。
|
|
1486
|
+
const loadedSnapshot = [...this.loaded.values()];
|
|
1487
|
+
for (const p of loadedSnapshot) {
|
|
1477
1488
|
for (const h of p.preGuards ?? []) {
|
|
1478
1489
|
let raw;
|
|
1479
1490
|
try {
|
|
@@ -1510,7 +1521,10 @@ export class PluginManager {
|
|
|
1510
1521
|
result: req.result,
|
|
1511
1522
|
conversationId: req.conversationId,
|
|
1512
1523
|
};
|
|
1513
|
-
|
|
1524
|
+
// #489:同 evaluateToolPre——await 点期间 reload 原地 clear Map 会让迭代器
|
|
1525
|
+
// 提前终止,取快照迭代保证本轮请求遍历的是发起时的插件集合。
|
|
1526
|
+
const postLoadedSnapshot = [...this.loaded.values()];
|
|
1527
|
+
for (const p of postLoadedSnapshot) {
|
|
1514
1528
|
for (const h of p.postGuards ?? []) {
|
|
1515
1529
|
let raw;
|
|
1516
1530
|
try {
|