pi-web-ui 0.95.0 → 0.96.1
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 +63 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +331 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-installer.js +30 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +718 -0
- package/themes/zhupi.css +771 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
- package/web/dist/assets/index-B7Zp23Vc.css +1 -0
- package/web/dist/assets/index-ByfoaDwp.js +366 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
|
@@ -16,14 +16,22 @@
|
|
|
16
16
|
* 不因为一条坏条目就停掉整批;
|
|
17
17
|
* 3. 回执是结构化的(ok/error/entries/installed),不靠 DOM 事件或控制台文字。
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
21
|
import { pick } from "./i18n.js";
|
|
22
22
|
import { normalizeSyncPayload, writeCustomCatalog } from "./plugin-catalog.js";
|
|
23
|
+
import { workspacePath } from "./files-service.js";
|
|
23
24
|
/**
|
|
24
|
-
* 读同步文档:`http(s)://`
|
|
25
|
-
*
|
|
25
|
+
* 读同步文档:`http(s)://` 走网络,其余当本地文件路径 —— **只允许工作区内的路径**
|
|
26
|
+
* (复用 files-service 的 workspacePath 校验;任意绝对路径可读会成为「文件存在 /
|
|
27
|
+
* 可读性」探测 oracle)。只读文本;本地来源的读失败与过大统一进 `localDocFailed`
|
|
28
|
+
* 这一条错误(调用方把解析失败也并进来),不区分「读不到 / 解析失败」两种失败。
|
|
26
29
|
*/
|
|
30
|
+
function localDocFailed(l) {
|
|
31
|
+
return {
|
|
32
|
+
error: pick(l, "目录文档无效或不可读(仅支持 http(s) URL 或工作区内的 JSON 文件)", "Invalid or unreadable catalog document (http(s) URL or an in-workspace JSON file only)", "plugincatalog.sync.doc.invalid"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
27
35
|
async function readDocument(source, deps, lang) {
|
|
28
36
|
const l = lang();
|
|
29
37
|
const maxBytes = Math.max(1024, Number(deps.maxBytes ?? 1024 * 1024));
|
|
@@ -51,24 +59,21 @@ async function readDocument(source, deps, lang) {
|
|
|
51
59
|
};
|
|
52
60
|
return { text };
|
|
53
61
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
// 本地来源:必须在工作区内(workspacePath 对相对/绝对路径统一做越界判定)。
|
|
63
|
+
// 越界 / 读不到 / 过大统一返回同一条错误,不给调用方区分的余地。
|
|
64
|
+
const wp = deps.workspaceRoot ? workspacePath(deps.workspaceRoot, source.trim()) : null;
|
|
65
|
+
if (!wp)
|
|
66
|
+
return localDocFailed(l);
|
|
67
|
+
let text;
|
|
59
68
|
try {
|
|
60
|
-
|
|
61
|
-
if (text.length > maxBytes)
|
|
62
|
-
return {
|
|
63
|
-
error: pick(l, `目录文档过大(> ${Math.round(maxBytes / 1024)} KB)`, `Catalog document too large (> ${Math.round(maxBytes / 1024)} KB)`, "plugincatalog.sync.too.large", { kb: String(Math.round(maxBytes / 1024)) }),
|
|
64
|
-
};
|
|
65
|
-
return { text };
|
|
69
|
+
text = readFileSync(wp.abs, "utf8");
|
|
66
70
|
}
|
|
67
|
-
catch
|
|
68
|
-
return
|
|
69
|
-
error: pick(l, `读取目录文件失败:${err?.message ?? err}`, `Failed to read the catalog file: ${err?.message ?? err}`, "plugincatalog.sync.read.failed", { reason: String(err?.message ?? err) }),
|
|
70
|
-
};
|
|
71
|
+
catch {
|
|
72
|
+
return localDocFailed(l);
|
|
71
73
|
}
|
|
74
|
+
if (text.length > maxBytes)
|
|
75
|
+
return localDocFailed(l);
|
|
76
|
+
return { text };
|
|
72
77
|
}
|
|
73
78
|
/**
|
|
74
79
|
* 同步一次目录:读 → 校验 → 原子写 → 可选安装 → 重载 + 重推。
|
|
@@ -86,11 +91,16 @@ export async function syncPluginCatalog(source, opts, deps) {
|
|
|
86
91
|
const doc = await readDocument(src, deps, lang);
|
|
87
92
|
if ("error" in doc)
|
|
88
93
|
return { ok: false, error: doc.error };
|
|
94
|
+
const isLocalSource = !/^https?:\/\//i.test(src);
|
|
89
95
|
let raw;
|
|
90
96
|
try {
|
|
91
97
|
raw = JSON.parse(doc.text);
|
|
92
98
|
}
|
|
93
99
|
catch (err) {
|
|
100
|
+
// 本地来源不区分「读得到但解析失败 / 读不到 / 越界」——组合起来就是文件探测
|
|
101
|
+
// oracle;http(s) 来源与本地文件无关,保留解析细节方便排障。
|
|
102
|
+
if (isLocalSource)
|
|
103
|
+
return { ok: false, error: localDocFailed(lang()).error };
|
|
94
104
|
return {
|
|
95
105
|
ok: false,
|
|
96
106
|
error: pick(l, `目录 JSON 解析失败:${err?.message ?? err}`, `Catalog JSON is not valid JSON: ${err?.message ?? err}`, "plugincatalog.sync.parse.failed", { reason: String(err?.message ?? err) }),
|
|
@@ -104,6 +114,13 @@ export async function syncPluginCatalog(source, opts, deps) {
|
|
|
104
114
|
await deps.afterWrite();
|
|
105
115
|
let installed;
|
|
106
116
|
if (opts.install === true && payload.entries.length) {
|
|
117
|
+
// 安装确认门(P0):安装/更新插件是高风险动作,必须先经用户确认。第三方页面
|
|
118
|
+
// 脚本可以直发 plugin_catalog_sync,没有这道门就能静默装任意插件。拒绝 /
|
|
119
|
+
// 超时 / 无确认设施(无头 DSH)一律只保留目录更新,不安装。
|
|
120
|
+
const items = payload.entries.map((e) => ({ id: e.id, source: e.source }));
|
|
121
|
+
const confirmed = deps.confirmInstall ? await deps.confirmInstall(items) : false;
|
|
122
|
+
if (!confirmed)
|
|
123
|
+
return { ok: true, installRefused: true, installed: [] };
|
|
107
124
|
installed = [];
|
|
108
125
|
for (const e of payload.entries) {
|
|
109
126
|
const action = existsSync(join(deps.pluginsDir, e.id)) ? "update" : "install";
|
|
@@ -92,7 +92,11 @@ function toEntry(raw, builtin) {
|
|
|
92
92
|
...(homepage ? { homepage } : {}),
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
/** 读合并后的目录:builtin 在前,custom 在后(同 id 时 custom 覆盖 builtin)。
|
|
95
|
+
/** 读合并后的目录:builtin 在前,custom 在后(同 id 时 custom 覆盖 builtin)。
|
|
96
|
+
* custom 覆盖 builtin 时强制打 `overridesBuiltin: true` 标记(builtin 同时为
|
|
97
|
+
* false):显示字段(name/icon/description)来自用户可写来源,可能仿冒官方条目,
|
|
98
|
+
* 前端据此把它标识为「自定义覆盖」。source 始终保留真实(custom)安装来源,
|
|
99
|
+
* 不允许伪装成官方来源。 */
|
|
96
100
|
export function readCatalog(builtinPath, customPath) {
|
|
97
101
|
const out = [];
|
|
98
102
|
const seen = new Set();
|
|
@@ -117,10 +121,13 @@ export function readCatalog(builtinPath, customPath) {
|
|
|
117
121
|
if (!e)
|
|
118
122
|
continue;
|
|
119
123
|
const idx = out.findIndex((x) => x.id === e.id);
|
|
120
|
-
if (idx >= 0)
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
if (idx >= 0) {
|
|
125
|
+
// 覆盖了同名 builtin 条目:强制带标识(不信任 custom 文件里的任何自标字段)。
|
|
126
|
+
out[idx] = { ...e, builtin: false, overridesBuiltin: true };
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
123
129
|
out.push(e);
|
|
130
|
+
}
|
|
124
131
|
seen.add(e.id);
|
|
125
132
|
}
|
|
126
133
|
return out;
|
|
@@ -21,6 +21,9 @@ import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as f
|
|
|
21
21
|
import { dirname, join, relative, resolve } from "node:path";
|
|
22
22
|
import { createRequire } from "node:module";
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
+
// 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
|
|
25
|
+
// 避免「同一个安全语义、两份实现」漂移。
|
|
26
|
+
import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
|
|
24
27
|
/** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
|
|
25
28
|
function atomicWrite(file, data) {
|
|
26
29
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -29,6 +32,43 @@ function atomicWrite(file, data) {
|
|
|
29
32
|
renameSync(tmp, file);
|
|
30
33
|
}
|
|
31
34
|
// ---------------------------------------------------------------------------
|
|
35
|
+
// storage.json 的进程内「读-改-写」互斥
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** 按文件路径的 RMW 互斥链(key = resolve 后的绝对路径)。 */
|
|
38
|
+
const rmwChains = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* 进程内「读-改-写」互斥(按文件路径的 promise 链锁)。
|
|
41
|
+
*
|
|
42
|
+
* storage.json 有两个读-改-写者:PluginStorage(插件 KV)与宿主 settings 面板的
|
|
43
|
+
* saveSettingsValues(plugins.ts,直写同一文件的 settings 键)。两段 RMW 必须互斥
|
|
44
|
+
* —— 现在两段关键区都是同步的(单线程下本就不会交错),但只要将来任何一处把
|
|
45
|
+
* 「读」和「写」之间插进 await,就会退回「旧快照整份回写、抹掉对方刚写的键」的
|
|
46
|
+
* 丢更新。两处统一收进这把锁:
|
|
47
|
+
* - 空链 + 同步 fn:原地直跑(不引入微任务延迟,set() 后同步 get() 立即可见,
|
|
48
|
+
* 语义与未加锁完全一致);fn 若返回 promise(异步写者)则占住链尾,后续排队;
|
|
49
|
+
* - 链忙:挂到链尾串行(前一个成功/失败都放行下一个)。
|
|
50
|
+
* Map 不主动清理:键数量 = 有 storage.json 的插件数,天然有界。
|
|
51
|
+
*/
|
|
52
|
+
export function withFileRmwLock(file, fn) {
|
|
53
|
+
const key = resolve(file);
|
|
54
|
+
const prev = rmwChains.get(key);
|
|
55
|
+
if (prev) {
|
|
56
|
+
const queued = prev.then(fn, fn);
|
|
57
|
+
rmwChains.set(key, queued.then(() => undefined, () => undefined));
|
|
58
|
+
return queued;
|
|
59
|
+
}
|
|
60
|
+
const result = fn();
|
|
61
|
+
if (result instanceof Promise) {
|
|
62
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
63
|
+
rmwChains.set(key, tail);
|
|
64
|
+
void tail.then(() => {
|
|
65
|
+
if (rmwChains.get(key) === tail)
|
|
66
|
+
rmwChains.delete(key);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
32
72
|
// storage
|
|
33
73
|
// ---------------------------------------------------------------------------
|
|
34
74
|
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
|
|
@@ -88,29 +128,37 @@ export class PluginStorage {
|
|
|
88
128
|
set(key, value) {
|
|
89
129
|
if (!key)
|
|
90
130
|
throw new Error("storage.set: key 不能为空");
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
// 与宿主 saveSettingsValues 同一把按文件路径的 RMW 锁(见 withFileRmwLock):
|
|
132
|
+
// 两段「读-改-写」互斥,谁也不会拿旧快照抹掉对方刚写的键。当前关键区是同步的
|
|
133
|
+
// (空链直跑),set() 后同步 get() 立即可见,行为与未加锁一致。
|
|
134
|
+
void withFileRmwLock(this.file, () => {
|
|
135
|
+
// 写前必重读磁盘:宿主 settings 面板的 saveSettingsValues 直写同一文件的
|
|
136
|
+
// settings 键(不经过本缓存),拿旧快照整份回写会把它抹掉。写是低频路径,
|
|
137
|
+
// 重读的代价可忽略;mtime 粒度即使同毫秒也抹不掉(这里是无条件重读)。
|
|
138
|
+
const store = this.readFromDisk();
|
|
139
|
+
store[key] = value;
|
|
140
|
+
try {
|
|
141
|
+
atomicWrite(this.file, JSON.stringify(store));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
102
147
|
}
|
|
103
148
|
delete(key) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
149
|
+
// 同 set:写前重读 + RMW 锁,不拿旧快照回写。
|
|
150
|
+
void withFileRmwLock(this.file, () => {
|
|
151
|
+
const store = this.readFromDisk();
|
|
152
|
+
if (!(key in store))
|
|
153
|
+
return;
|
|
154
|
+
delete store[key];
|
|
155
|
+
try {
|
|
156
|
+
atomicWrite(this.file, JSON.stringify(store));
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
114
162
|
}
|
|
115
163
|
}
|
|
116
164
|
function seal(key, plaintext) {
|
|
@@ -362,6 +410,24 @@ export class WorkspaceFS {
|
|
|
362
410
|
}
|
|
363
411
|
return target;
|
|
364
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* 写类操作(write/append/mkdir/remove)的 realpath 复核:abs() 是纯字符串
|
|
415
|
+
* 比较,工作区里的符号链接/junction 能把写入(或递归删除)引到工作区之外。
|
|
416
|
+
* 取目标最近已存在祖先的 realpath,复核它仍在「活根」的 realpath 内。
|
|
417
|
+
* 读/list/stat/glob 保持字符串校验(高频路径;读不存在「把内容写到别处」
|
|
418
|
+
* 的风险,性能优先 —— 有意的取舍)。
|
|
419
|
+
*/
|
|
420
|
+
assertRealInsideRoot(rel) {
|
|
421
|
+
const target = this.abs(rel); // 字符串越界先拒绝(错误文案不变)
|
|
422
|
+
const targetReal = realPathOfNearest(target);
|
|
423
|
+
if (!targetReal)
|
|
424
|
+
throw new Error(`路径越界:无法解析真实路径 ${String(rel)}`);
|
|
425
|
+
const rootDir = resolve(this.root());
|
|
426
|
+
const rootReal = realPathOfNearest(rootDir) ?? rootDir;
|
|
427
|
+
if (!isInsideRoot(rootReal, targetReal)) {
|
|
428
|
+
throw new Error(`路径越界(符号链接指向工作区之外):${String(rel)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
365
431
|
/** 单层目录列表(浅层;深度遍历请插件自行递归)。 */
|
|
366
432
|
async list(relDir = "") {
|
|
367
433
|
try {
|
|
@@ -386,18 +452,21 @@ export class WorkspaceFS {
|
|
|
386
452
|
}
|
|
387
453
|
/** 写文件(自动补父目录;注意相对路径锚定当前项目——切换 cwd 后写进新项目)。 */
|
|
388
454
|
async write(relPath, data) {
|
|
455
|
+
this.assertRealInsideRoot(relPath);
|
|
389
456
|
const target = this.abs(relPath);
|
|
390
457
|
await fspMkdir(dirname(target), { recursive: true });
|
|
391
458
|
await fspWriteFile(target, data);
|
|
392
459
|
}
|
|
393
460
|
/** 追加写文件(日志/队列场景;父目录自动补;越界拒绝与 write 同口径)。 */
|
|
394
461
|
async append(relPath, data) {
|
|
462
|
+
this.assertRealInsideRoot(relPath);
|
|
395
463
|
const target = this.abs(relPath);
|
|
396
464
|
await fspMkdir(dirname(target), { recursive: true });
|
|
397
465
|
await fspAppendFile(target, data);
|
|
398
466
|
}
|
|
399
467
|
/** 建目录(递归;已存在幂等成功;越界拒绝与 write 同口径)。 */
|
|
400
468
|
async mkdir(relDir) {
|
|
469
|
+
this.assertRealInsideRoot(relDir);
|
|
401
470
|
await fspMkdir(this.abs(relDir), { recursive: true });
|
|
402
471
|
}
|
|
403
472
|
/** 文件元信息(size/mtime 供同步/缓存判断;不存在抛错)。 */
|
|
@@ -457,6 +526,8 @@ export class WorkspaceFS {
|
|
|
457
526
|
}
|
|
458
527
|
/** 删除文件/目录(递归;只允许删工作区内的路径)。 */
|
|
459
528
|
async remove(relPath) {
|
|
529
|
+
// remove 同样做 realpath 复核:递归删除跟着目录链接走,比写文件更危险。
|
|
530
|
+
this.assertRealInsideRoot(relPath);
|
|
460
531
|
await fspRm(this.abs(relPath), { recursive: true, force: false });
|
|
461
532
|
}
|
|
462
533
|
}
|
|
@@ -315,3 +315,33 @@ export class PluginInstaller {
|
|
|
315
315
|
this.currentJobId = null;
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* 插件安装的用户确认门(P0):plugin_catalog_sync 的 install:true 与 plugin_job
|
|
320
|
+
* 的 install/update 在真正动安装器之前必须拿到用户确认。
|
|
321
|
+
*
|
|
322
|
+
* 用户若选择「记住并允许」,授权存入 plugin-permissions.json(后续同类操作自动放行,
|
|
323
|
+
* 设置面板「能力授权」页可审计与撤销)。拒绝 / 超时 / 未接弹窗设施(无头 DSH)一律 fail-closed。
|
|
324
|
+
*/
|
|
325
|
+
export async function confirmPluginInstall(items, deps) {
|
|
326
|
+
if (deps.permGrants?.has("plugin-installer", "net", { host: "github.com" })) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const ask = deps.permissionRequester;
|
|
330
|
+
if (!ask)
|
|
331
|
+
return false;
|
|
332
|
+
const list = items.map((x) => `${x.id} ← ${x.source}`).join("\n");
|
|
333
|
+
const ans = await ask("plugin-installer", {
|
|
334
|
+
family: "net",
|
|
335
|
+
hosts: ["github.com"],
|
|
336
|
+
reason: `安装确认:将安装/更新以下插件(id ← source):\n${list}\n拒绝或 120 秒未确认则不安装。`,
|
|
337
|
+
});
|
|
338
|
+
if (ans.ok && ans.remember && deps.permGrants) {
|
|
339
|
+
deps.permGrants.grant("plugin-installer", "net", {
|
|
340
|
+
hosts: ["github.com"],
|
|
341
|
+
reason: "插件安装/更新确认",
|
|
342
|
+
remember: true,
|
|
343
|
+
});
|
|
344
|
+
deps.onGrantsChanged?.();
|
|
345
|
+
}
|
|
346
|
+
return ans.ok === true;
|
|
347
|
+
}
|
|
@@ -48,82 +48,88 @@ export async function completeWithIsolatedSession(env, req) {
|
|
|
48
48
|
const maxChars = Math.min(Math.max(Number(req?.maxChars ?? DEFAULT_MAX_CHARS) || DEFAULT_MAX_CHARS, 1), MAX_MAX_CHARS);
|
|
49
49
|
const timeoutMs = Math.min(Math.max(Number(req?.timeoutMs ?? DEFAULT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);
|
|
50
50
|
inflight += 1;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
modelName = `${env.fallbackModel.provider}/${env.fallbackModel.id}`;
|
|
81
|
-
}
|
|
82
|
-
const srv = await createAgentSessionFromServices({
|
|
83
|
-
services,
|
|
84
|
-
sessionManager: SessionManager.inMemory(env.cwd),
|
|
85
|
-
...(model ? { model } : {}),
|
|
86
|
-
// 全部工具关闭("all"):纯补全,无副作用(SDK 的 noTools 是 "builtin"|"all" 枚举)。
|
|
87
|
-
noTools: "all",
|
|
88
|
-
});
|
|
89
|
-
dispose = () => {
|
|
90
|
-
try {
|
|
91
|
-
srv.session.dispose();
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
/* dispose 尽力而为 */
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
const text = system.trim() ? `系统指令:${system.trim()}\n\n---\n\n任务:${prompt}` : prompt;
|
|
98
|
-
await srv.session.prompt(text, { expandPromptTemplates: false });
|
|
99
|
-
const raw = srv.session.getLastAssistantText() ?? "";
|
|
100
|
-
let usage;
|
|
101
|
-
try {
|
|
102
|
-
const stats = srv.session.getSessionStats();
|
|
103
|
-
usage = { input: stats.tokens.input, output: stats.tokens.output };
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
/* usage 尽力而为 */
|
|
107
|
-
}
|
|
108
|
-
return { ok: true, text: raw.slice(0, maxChars), model: modelName, ...(usage ? { usage } : {}) };
|
|
51
|
+
const run = (async () => {
|
|
52
|
+
let dispose;
|
|
53
|
+
try {
|
|
54
|
+
const services = await createAgentSessionServices({
|
|
55
|
+
cwd: env.cwd,
|
|
56
|
+
agentDir: env.agentDir,
|
|
57
|
+
// 技能全剥:纯补全不需要技能上下文(省 token,结果可预测)。
|
|
58
|
+
resourceLoaderOptions: {
|
|
59
|
+
skillsOverride: (res) => ({ ...res, skills: [] }),
|
|
60
|
+
},
|
|
61
|
+
modelRuntime: await ModelRuntime.create({
|
|
62
|
+
authPath: join(env.agentDir, "auth.json"),
|
|
63
|
+
modelsPath: join(env.agentDir, "models.json"),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
// 模型:显式指定 > 主会话回落 > 会话默认(与 reviewer 同优先级)。
|
|
67
|
+
let model;
|
|
68
|
+
let modelName = "default";
|
|
69
|
+
const spec = parseModelSpec(req?.model);
|
|
70
|
+
if (spec) {
|
|
71
|
+
model = services.modelRuntime.getModel(spec.provider, spec.id);
|
|
72
|
+
if (!model)
|
|
73
|
+
return fail(`llm.complete: 找不到模型 ${spec.spec}`);
|
|
74
|
+
modelName = spec.spec;
|
|
75
|
+
}
|
|
76
|
+
if (!model && env.fallbackModel) {
|
|
77
|
+
model = services.modelRuntime.getModel(env.fallbackModel.provider, env.fallbackModel.id);
|
|
78
|
+
if (model)
|
|
79
|
+
modelName = `${env.fallbackModel.provider}/${env.fallbackModel.id}`;
|
|
109
80
|
}
|
|
110
|
-
|
|
111
|
-
|
|
81
|
+
const srv = await createAgentSessionFromServices({
|
|
82
|
+
services,
|
|
83
|
+
sessionManager: SessionManager.inMemory(env.cwd),
|
|
84
|
+
...(model ? { model } : {}),
|
|
85
|
+
// 全部工具关闭("all"):纯补全,无副作用(SDK 的 noTools 是 "builtin"|"all" 枚举)。
|
|
86
|
+
noTools: "all",
|
|
87
|
+
});
|
|
88
|
+
dispose = () => {
|
|
112
89
|
try {
|
|
113
|
-
dispose
|
|
90
|
+
srv.session.dispose();
|
|
114
91
|
}
|
|
115
92
|
catch {
|
|
116
93
|
/* dispose 尽力而为 */
|
|
117
94
|
}
|
|
95
|
+
};
|
|
96
|
+
const text = system.trim() ? `系统指令:${system.trim()}\n\n---\n\n任务:${prompt}` : prompt;
|
|
97
|
+
await srv.session.prompt(text, { expandPromptTemplates: false });
|
|
98
|
+
const raw = srv.session.getLastAssistantText() ?? "";
|
|
99
|
+
let usage;
|
|
100
|
+
try {
|
|
101
|
+
const stats = srv.session.getSessionStats();
|
|
102
|
+
usage = { input: stats.tokens.input, output: stats.tokens.output };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* usage 尽力而为 */
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, text: raw.slice(0, maxChars), model: modelName, ...(usage ? { usage } : {}) };
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
// 会话回收跟 run 走:超时竞速输了 run 还在飞,run 落定才 dispose,不留孤儿。
|
|
111
|
+
try {
|
|
112
|
+
dispose?.();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* dispose 尽力而为 */
|
|
118
116
|
}
|
|
119
|
-
}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
// 并发计数跟**真实 run** 走,而不是外层函数的 finally:超时竞速返回后 run 还在飞,
|
|
120
|
+
// 计数必须继续占着 MAX_INFLIGHT 的位 —— 否则每次超时都「腾出」一路并发,
|
|
121
|
+
// 连续超时的插件能把在飞会话越堆越多,护栏失效。catch 挂在分支上兜住 run 的
|
|
122
|
+
// 拒绝(竞速已由 timer 赢时,外层 catch 不会再收到它)。
|
|
123
|
+
void run
|
|
124
|
+
.catch(() => { })
|
|
125
|
+
.finally(() => {
|
|
126
|
+
inflight -= 1;
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
120
129
|
const timer = new Promise((resolve) => setTimeout(() => resolve(fail(`llm.complete: 超时(约${Math.round(timeoutMs / 1000)}s)`)), timeoutMs));
|
|
121
130
|
return await Promise.race([run, timer]);
|
|
122
131
|
}
|
|
123
132
|
catch (err) {
|
|
124
133
|
return fail(`llm.complete: ${err.message}`);
|
|
125
134
|
}
|
|
126
|
-
finally {
|
|
127
|
-
inflight -= 1;
|
|
128
|
-
}
|
|
129
135
|
}
|
|
@@ -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 {
|
|
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
|
}
|
|
@@ -394,6 +418,28 @@ export async function createProject(spec, deps = {}) {
|
|
|
394
418
|
return undefined;
|
|
395
419
|
};
|
|
396
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
|
+
};
|
|
397
443
|
for (const r of plannedRepos) {
|
|
398
444
|
const atRoot = samePath(r.dest, root);
|
|
399
445
|
// 根目录已存在是常态(它就是那个已存在的授权目录),既不 rm 也不 mkdir ——
|
|
@@ -420,10 +466,32 @@ export async function createProject(spec, deps = {}) {
|
|
|
420
466
|
if (why)
|
|
421
467
|
return fail(`${why}${res.stderrTail === "" ? "" : `:${res.stderrTail}`}`);
|
|
422
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
|
+
}
|
|
423
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
|
+
}
|
|
424
491
|
emit(`write ${f.rel}`);
|
|
425
492
|
mkdirSync(dirname(f.abs), { recursive: true });
|
|
426
493
|
writeFileSync(f.abs, f.content, "utf8");
|
|
494
|
+
writtenThisRun.push(f.abs);
|
|
427
495
|
}
|
|
428
496
|
if (spec?.gitInit === true) {
|
|
429
497
|
emit("git init");
|