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
|
@@ -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
|
-
/**
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
}
|
package/dist/server/plugins.js
CHANGED
|
@@ -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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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:
|
|
243
|
-
"Use
|
|
244
|
-
"Give workspace-relative paths (
|
|
245
|
-
"Do not use
|
|
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
|
-
|
|
252
|
-
|
|
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
|
+
}
|