pi-web-ui 0.94.1 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- 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 +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- 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-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- 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/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- 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/workspace-snapshot.js +113 -0
- 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 +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -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-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
package/dist/server/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { createConnection } from "node:net";
|
|
|
23
23
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
27
27
|
import express from "express";
|
|
28
28
|
import compression from "compression";
|
|
29
29
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -32,7 +32,9 @@ import { sdkCopies, sdkOriginNote } from "./sdk-origin.js";
|
|
|
32
32
|
import { PROTOCOL_VERSION } from "./protocol-version.js";
|
|
33
33
|
import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
|
|
34
34
|
import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
35
|
+
import { httpHostAllowed } from "./host-guard.js";
|
|
35
36
|
import { registerFileTransferRoutes } from "./file-transfer-routes.js";
|
|
37
|
+
import { initAttachmentStore, readAttachment } from "./attachment-store.js";
|
|
36
38
|
import { isAudioFile, previewKind } from "./text-sniff.js";
|
|
37
39
|
import { startControlServer } from "./control-socket.js";
|
|
38
40
|
import { scheduleUploadCleanup } from "./uploads.js";
|
|
@@ -42,15 +44,18 @@ import { isManaged, managedRefusal } from "./managed.js";
|
|
|
42
44
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
43
45
|
import { parseTabs, tabsRefusal } from "./tabs.js";
|
|
44
46
|
import { recordUnknownWsType } from "./ws-unknown-types.js";
|
|
47
|
+
import { validateClientId } from "./ws-client-id.js";
|
|
48
|
+
import { PendingCommandQueue } from "./ws-pending-queue.js";
|
|
45
49
|
import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
|
|
46
50
|
import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
|
|
47
|
-
import { PluginInstaller } from "./plugin-installer.js";
|
|
51
|
+
import { buildPluginJobArgs, inspectInstallSpec, PluginInstaller } from "./plugin-installer.js";
|
|
48
52
|
import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
49
53
|
import { McpBridge } from "./mcp-bridge.js";
|
|
50
54
|
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
51
55
|
import { createHostMetricsSampler } from "./host-metrics.js";
|
|
52
56
|
import { SchedulerStore } from "./scheduler-tasks.js";
|
|
53
57
|
import { initHttpProxy } from "./http-proxy.js";
|
|
58
|
+
import { globalLspPool } from "./lsp-tool.js";
|
|
54
59
|
import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
|
|
55
60
|
/** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
|
|
56
61
|
* `npm_lifecycle_*` into every child). Anything this server spawns — shells,
|
|
@@ -95,6 +100,19 @@ try {
|
|
|
95
100
|
catch (err) {
|
|
96
101
|
console.warn(`[data] 无法创建数据目录 ${DATA_DIR}: ${err.message}`);
|
|
97
102
|
}
|
|
103
|
+
// issue #295:工作区直接就是家目录时,SDK 初始化期的同步目录扫描会落在 $HOME 上
|
|
104
|
+
// (iCloud 占位符/外部卷坏挂载 → scandir/open 内核挂起 → 事件循环假死,hello 后无
|
|
105
|
+
// ready)。新装服务默认已是 ~/pi-web-ui(见 bin/pi-web-ui.mjs serviceOptions);
|
|
106
|
+
// 老服务若仍指着家目录,在此提示一次,`server install` 重装即迁移。
|
|
107
|
+
try {
|
|
108
|
+
if (resolve(CWD) === resolve(homedir())) {
|
|
109
|
+
console.warn(`[ws] 工作区为用户主目录 ${CWD}:目录扫描可能因外部卷/同步盘挂载而长时间阻塞,` +
|
|
110
|
+
`建议用 \`pi-web-ui server install --cwd <项目目录>\` 重装迁移。`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* 路径比较失败不影响启动 */
|
|
115
|
+
}
|
|
98
116
|
// Dev-no-cache setting read without a ClientStateStore instance (the index.html
|
|
99
117
|
// route runs before any client attaches). Reads the same global settings blob.
|
|
100
118
|
function readDevNoCacheSetting() {
|
|
@@ -188,6 +206,19 @@ if (process.platform === "win32") {
|
|
|
188
206
|
void ensureWindowsBash();
|
|
189
207
|
}
|
|
190
208
|
const app = express();
|
|
209
|
+
// Host 白名单(防 DNS rebinding,审查 #352):无 token 部署下 HTTP 路由此前
|
|
210
|
+
// 不校验 Host,恶意网页让自己的域名解析到 127.0.0.1 即可打满全部 API。
|
|
211
|
+
// 显式白名单走 PI_WEB_ALLOW_HOSTS(与 WS 侧同 env);设置了 PI_WEB_TOKEN 则
|
|
212
|
+
// 由 token 鉴权兜底,不再限制 Host。WS 升级侧同规则见 originAllowed()。
|
|
213
|
+
app.use((req, res, next) => {
|
|
214
|
+
const hostHeader = req.headers.host;
|
|
215
|
+
if (typeof hostHeader === "string" &&
|
|
216
|
+
!httpHostAllowed(hostHeader, { allowHosts: ALLOW_HOSTS, hasAuthToken: Boolean(AUTH_TOKEN) })) {
|
|
217
|
+
res.status(403).end("host not allowed");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
next();
|
|
221
|
+
});
|
|
191
222
|
app.use(express.json({ limit: "10mb" }));
|
|
192
223
|
/** 从请求中提取候选 token:头 / 查询参数 / cookie(浏览器导航场景靠 cookie 续命)。 */
|
|
193
224
|
function requestTokens(req) {
|
|
@@ -225,8 +256,15 @@ function requestTokens(req) {
|
|
|
225
256
|
}
|
|
226
257
|
return out.filter(Boolean);
|
|
227
258
|
}
|
|
259
|
+
/** 口令比较用常时时间:先哈希到定长再 timingSafeEqual(长度差异被摘要抹平),
|
|
260
|
+
* 消除逐字节短路比较的时序侧信道(审查 #352:纵深防御,远程可利用性低)。 */
|
|
261
|
+
function sameSecret(candidate) {
|
|
262
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
263
|
+
const b = createHash("sha256").update(AUTH_TOKEN).digest();
|
|
264
|
+
return timingSafeEqual(a, b);
|
|
265
|
+
}
|
|
228
266
|
function tokenOk(req) {
|
|
229
|
-
return requestTokens(req).
|
|
267
|
+
return requestTokens(req).some(sameSecret);
|
|
230
268
|
}
|
|
231
269
|
/** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
|
|
232
270
|
* 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
|
|
@@ -268,7 +306,7 @@ if (AUTH_TOKEN) {
|
|
|
268
306
|
// cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
|
|
269
307
|
// 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
|
|
270
308
|
// 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
|
|
271
|
-
if (cookie
|
|
309
|
+
if (!sameSecret(cookie)) {
|
|
272
310
|
res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
|
|
273
311
|
}
|
|
274
312
|
}
|
|
@@ -305,17 +343,47 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
|
|
|
305
343
|
const TABS = parseTabs();
|
|
306
344
|
registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
|
|
307
345
|
app.get("/api/health", (_req, res) => {
|
|
346
|
+
// 审查 #352:该端点对未鉴权开放(监控探针需要),故只保留版本/引擎与 SDK
|
|
347
|
+
// 副本诊断信息,不再暴露 cwd(工作区路径披露)与 pid(指纹/信息收集面)。
|
|
308
348
|
res.json({
|
|
309
349
|
ok: true,
|
|
310
350
|
piVersion: VERSION,
|
|
311
351
|
// issue #260:服务实际加载的是自带副本,不是全局 pi CLI 那份。这里把两份都报出来,
|
|
312
352
|
// 用户就不用猜「为什么升了全局 SDK 不生效」。(纯新增字段,piVersion 语义不变。)
|
|
313
353
|
piSdkCopies: sdkCopies(),
|
|
314
|
-
cwd: CWD,
|
|
315
|
-
pid: process.pid,
|
|
316
354
|
engine: ENGINE,
|
|
317
355
|
});
|
|
318
356
|
});
|
|
357
|
+
/**
|
|
358
|
+
* 基于 SHA-256 内容寻址的附件静态服务:
|
|
359
|
+
* 永久强缓存(immutable),支持图片和文件读取。
|
|
360
|
+
*/
|
|
361
|
+
app.get("/api/attachment/:hash", async (req, res) => {
|
|
362
|
+
try {
|
|
363
|
+
const hash = String(req.params.hash ?? "").trim();
|
|
364
|
+
const hit = await readAttachment(hash);
|
|
365
|
+
if (!hit) {
|
|
366
|
+
res.status(404).end("attachment not found");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
res.setHeader("Content-Type", hit.mimeType);
|
|
370
|
+
res.setHeader("Content-Length", hit.buffer.length);
|
|
371
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
372
|
+
if (hit.mimeType === "image/svg+xml") {
|
|
373
|
+
// SVG 以文档形态打开(顶层导航/iframe)时内嵌 <script> 会在本应用
|
|
374
|
+
// origin 执行——附件内容可来自剪贴板/工作区文件,等同存储型 XSS
|
|
375
|
+
// (审查 #352 P0-1)。sandbox CSP 让文档形态降级到不透明 origin;
|
|
376
|
+
// <img> 引用不受影响(子资源响应的 CSP 不作用于引用页,且
|
|
377
|
+
// SVG-as-image 本就不执行脚本)。
|
|
378
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
379
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
380
|
+
}
|
|
381
|
+
res.end(hit.buffer);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
res.status(500).end(err.message);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
319
387
|
/**
|
|
320
388
|
* Stream a workspace file over HTTP.
|
|
321
389
|
*
|
|
@@ -376,6 +444,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
376
444
|
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
|
|
377
445
|
// 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
|
|
378
446
|
// 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
|
|
447
|
+
// 注:绝对 wire 路径分支是机器浏览设计(whole-machine browsing),
|
|
448
|
+
// 不受工作区约束——别被注释误导(审查 #352)。
|
|
379
449
|
res.download(abs, name, { dotfiles: "allow" });
|
|
380
450
|
}
|
|
381
451
|
else {
|
|
@@ -393,6 +463,12 @@ app.get("/api/file", async (req, res) => {
|
|
|
393
463
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
394
464
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
395
465
|
}
|
|
466
|
+
else if (lower.endsWith(".svg")) {
|
|
467
|
+
// 与 /api/attachment 同理:SVG 文档形态的 <script> 沙箱化
|
|
468
|
+
// (审查 #352 P0-1),<img> 内嵌用法不受影响。
|
|
469
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
470
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
471
|
+
}
|
|
396
472
|
res.sendFile(abs, { dotfiles: "allow" });
|
|
397
473
|
}
|
|
398
474
|
}
|
|
@@ -457,6 +533,10 @@ app.get("/api/preview/*splat", async (req, res) => {
|
|
|
457
533
|
const allowJs = req.query.allowJs === "1";
|
|
458
534
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
459
535
|
}
|
|
536
|
+
else if (lower.endsWith(".svg")) {
|
|
537
|
+
// 与 /api/attachment 同理:SVG 文档形态的 <script> 沙箱化(审查 #352 P0-1)。
|
|
538
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
539
|
+
}
|
|
460
540
|
res.sendFile(abs, { dotfiles: "allow" });
|
|
461
541
|
}
|
|
462
542
|
catch {
|
|
@@ -774,8 +854,13 @@ function parseAuthority(a) {
|
|
|
774
854
|
}
|
|
775
855
|
}
|
|
776
856
|
function originAllowed(req) {
|
|
777
|
-
const hostHeader =
|
|
778
|
-
|
|
857
|
+
const hostHeader = req.headers.host ?? "";
|
|
858
|
+
// Host 白名单与 HTTP 侧同规则(审查 #352):rebinding 下 Origin 会与
|
|
859
|
+
// 攻击者 Host 自比相等,必须先把非本机/私网的 Host 挡掉。
|
|
860
|
+
if (!httpHostAllowed(hostHeader, { allowHosts: ALLOW_HOSTS, hasAuthToken: Boolean(AUTH_TOKEN) })) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
const host = parseAuthority(hostHeader.toLowerCase());
|
|
779
864
|
if (ALLOW_HOSTS.length > 0 && !ALLOW_HOSTS.includes(host.hostname)) {
|
|
780
865
|
return false;
|
|
781
866
|
}
|
|
@@ -918,6 +1003,7 @@ const service = ENGINE === "dsh"
|
|
|
918
1003
|
join(DATA_DIR, "client-state.json"));
|
|
919
1004
|
// Server-string tables (issue #91 v2): packs' `serverStrings` sections feed
|
|
920
1005
|
// pick() lookup for non-zh/en UI languages (missing key → English fallback).
|
|
1006
|
+
initAttachmentStore(DATA_DIR);
|
|
921
1007
|
loadServerStrings(DATA_DIR);
|
|
922
1008
|
// Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
|
|
923
1009
|
// attach so freshly dropped plugins appear without a server restart.
|
|
@@ -953,7 +1039,9 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
|
|
|
953
1039
|
pendingPathRequests.delete(id);
|
|
954
1040
|
resolve(false);
|
|
955
1041
|
}, 120_000);
|
|
956
|
-
|
|
1042
|
+
// 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
|
|
1043
|
+
const recipients = new Set(pluginMgr.onlineClientIds());
|
|
1044
|
+
pendingPathRequests.set(id, { resolve, timer, recipients });
|
|
957
1045
|
const payload = JSON.stringify({
|
|
958
1046
|
type: "plugin_path_request",
|
|
959
1047
|
id,
|
|
@@ -1006,7 +1094,9 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
1006
1094
|
}
|
|
1007
1095
|
resolve({ ok: false, remember: false });
|
|
1008
1096
|
}, 120_000);
|
|
1009
|
-
|
|
1097
|
+
// 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
|
|
1098
|
+
const recipients = new Set(pluginMgr.onlineClientIds());
|
|
1099
|
+
pendingPermissionRequests.set(id, { resolve, timer, recipients });
|
|
1010
1100
|
const ask = JSON.stringify({
|
|
1011
1101
|
type: "plugin_permission_request",
|
|
1012
1102
|
id,
|
|
@@ -1028,6 +1118,34 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
1028
1118
|
}
|
|
1029
1119
|
});
|
|
1030
1120
|
pluginMgr.onPermGrantsChanged = () => pushPluginPermissions();
|
|
1121
|
+
/** key = pluginId:wantsDom 插件集合有限 + 120s 自动过期,规模天然有界。 */
|
|
1122
|
+
const pendingDomConsents = new Map();
|
|
1123
|
+
/** 按 id 反查在途 consent 请求(应答只有 id;表很小,线性扫即可)。 */
|
|
1124
|
+
function findDomConsentById(id) {
|
|
1125
|
+
for (const p of pendingDomConsents.values())
|
|
1126
|
+
if (p.id === id)
|
|
1127
|
+
return p;
|
|
1128
|
+
return undefined;
|
|
1129
|
+
}
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
// 插件安装的用户确认门(P0):plugin_catalog_sync 的 install:true 与 plugin_job
|
|
1132
|
+
// 的 install/update 在真正动安装器之前必须拿到用户确认。第三方页面脚本可以直发
|
|
1133
|
+
// 这两类消息,没有这道门就能借 WS 静默安装任意插件。复用 permissionRequester
|
|
1134
|
+
// 的同一条「plugin_permission_request 弹窗 + 120s 超时视为拒绝」管线 —— 不新增
|
|
1135
|
+
// 协议消息,将安装清单放在 reason 里展示;这里直接调 requester(不写授权表,
|
|
1136
|
+
// remember 标志被忽略)。拒绝 / 超时 / 未接弹窗设施(无头 DSH)一律 fail-closed。
|
|
1137
|
+
// ---------------------------------------------------------------------------
|
|
1138
|
+
async function confirmPluginInstall(items) {
|
|
1139
|
+
const ask = pluginMgr.permissionRequester;
|
|
1140
|
+
if (!ask)
|
|
1141
|
+
return false;
|
|
1142
|
+
const list = items.map((x) => `${x.id} ← ${x.source}`).join("\n");
|
|
1143
|
+
const ans = await ask("plugin-installer", {
|
|
1144
|
+
family: "net",
|
|
1145
|
+
reason: `安装确认:将安装/更新以下插件(id ← source):\n${list}\n拒绝或 120 秒未确认则不安装。`,
|
|
1146
|
+
});
|
|
1147
|
+
return ans.ok === true;
|
|
1148
|
+
}
|
|
1031
1149
|
// 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
|
|
1032
1150
|
// client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
|
|
1033
1151
|
// wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
|
|
@@ -1139,6 +1257,17 @@ const scheduler = new SchedulerStore(DATA_DIR, {
|
|
|
1139
1257
|
},
|
|
1140
1258
|
onChange: () => pushSchedulerTasks(),
|
|
1141
1259
|
notify: (level, text, textEn) => pushNoticeToAll(level, text, textEn ?? text),
|
|
1260
|
+
// issue #291:任务删除(含单次任务跑完自删)后回收其伪客户端,
|
|
1261
|
+
// 否则 scheduler:<taskId> 会话会永久占据其他客户端的 elsewhere 列表。
|
|
1262
|
+
onTaskRemoved: (taskId) => {
|
|
1263
|
+
const svc = service;
|
|
1264
|
+
try {
|
|
1265
|
+
svc.releaseSchedulerClient?.(taskId);
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
// 回收失败不影响任务删除
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1142
1271
|
});
|
|
1143
1272
|
scheduler.start();
|
|
1144
1273
|
/** 把调度器任务列表推给所有在线客户端(设置面板展示 + 变更后刷新)。 */
|
|
@@ -1194,6 +1323,12 @@ void mcpBridge.load().then(() => {
|
|
|
1194
1323
|
});
|
|
1195
1324
|
// 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
|
|
1196
1325
|
service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
|
|
1326
|
+
// 插件扩展点(P1-5):bash/read 执行前后的拦截(pre 拒/问即拦、post 脱敏补上下文;
|
|
1327
|
+
// 只覆盖已接管的这两处,DSH 引擎无 customTool 注册面不接)。
|
|
1328
|
+
service.toolGuard = {
|
|
1329
|
+
pre: (req, lang) => pluginMgr.evaluateToolPre(req, lang),
|
|
1330
|
+
post: (req, lang) => pluginMgr.evaluateToolPost(req, lang),
|
|
1331
|
+
};
|
|
1197
1332
|
service.onRunEvent = (ev) => pluginMgr.emitRunEvent(ev);
|
|
1198
1333
|
// 插件扩展点:对话切换通知(轨迹视图切会话后即重拉;dsh 引擎暂无)。
|
|
1199
1334
|
service.onConversationChanged = () => pluginMgr.emitConversationChanged();
|
|
@@ -1384,6 +1519,10 @@ const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
|
|
|
1384
1519
|
const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
|
|
1385
1520
|
/** 背压丢弃后的延迟重发间隔。 */
|
|
1386
1521
|
const SNAPSHOT_RETRY_MS = 250;
|
|
1522
|
+
/** issue #295:attach(会话初始化)超过此时长未完成,先给浏览器一句可见提示,
|
|
1523
|
+
* 避免界面永久停在「正在连接」而用户不知发生了什么。attach 本体继续等(不取消),
|
|
1524
|
+
* 完成后照常走快照流程。 */
|
|
1525
|
+
const ATTACH_SLOW_NOTICE_MS = 8_000;
|
|
1387
1526
|
/**
|
|
1388
1527
|
* Multi-tab serialization sharing: emit() hands the SAME message object to
|
|
1389
1528
|
* every socket of a client, but each send() used to JSON.stringify it
|
|
@@ -1408,8 +1547,13 @@ wss.on("connection", (ws) => {
|
|
|
1408
1547
|
let closed = false;
|
|
1409
1548
|
/** 最近一份全量 snapshot 的估算字节数(UTF-16 ×2),供背压相对阈值用(issue #11)。 */
|
|
1410
1549
|
let lastSnapshotBytes = 0;
|
|
1411
|
-
/** Commands received while the session is still being created — replayed after attach.
|
|
1412
|
-
|
|
1550
|
+
/** Commands received while the session is still being created — replayed after attach.
|
|
1551
|
+
* 带上限(256 条):attach 挂死/失败保活期间队列不再无界增长,超限丢最旧并告警。 */
|
|
1552
|
+
const pending = new PendingCommandQueue();
|
|
1553
|
+
/** attach 完成(含插件链 + 首快照)前一律排队(见 hello 分支的 replayQueued):
|
|
1554
|
+
* ready 先行后,ready 只代表传输通,插件命令目录/首快照都还没好,直接分发
|
|
1555
|
+
* 会撞「未知命令」/ rev 链断裂。 */
|
|
1556
|
+
let attachDone = false;
|
|
1413
1557
|
/** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
|
|
1414
1558
|
let snapshotRetryTimer = null;
|
|
1415
1559
|
// 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
|
|
@@ -1460,14 +1604,30 @@ wss.on("connection", (ws) => {
|
|
|
1460
1604
|
// Plugins broadcast to every open socket; unregister on close below. The
|
|
1461
1605
|
// cid getter lets plugins target THIS socket via host.sendTo(clientId).
|
|
1462
1606
|
const removePluginSender = pluginMgr.addSender(send, () => clientId);
|
|
1607
|
+
/** DOM 授权落盘 + notice 反馈(两步握手的 grant 与单步 revoke 共用收尾)。
|
|
1608
|
+
* cs 缺席(未 attach 完成)时只落盘不提示——消息本来就会在 attach 前排队。 */
|
|
1609
|
+
const applyDomConsent = (pluginId, granted) => {
|
|
1610
|
+
void pluginMgr
|
|
1611
|
+
.setDomConsent(pluginId, granted)
|
|
1612
|
+
.then((r) => {
|
|
1613
|
+
const cs2 = clientId ? service.get(clientId) : undefined;
|
|
1614
|
+
if (r.error)
|
|
1615
|
+
cs2?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
|
|
1616
|
+
else if (r.changed)
|
|
1617
|
+
cs2?.emitNotice("info", granted ? `已授权插件「${pluginId}」完全 DOM 访问` : `已撤销插件「${pluginId}」完全 DOM 访问`, granted
|
|
1618
|
+
? `Granted full DOM access to plugin "${pluginId}"`
|
|
1619
|
+
: `Revoked full DOM access from plugin "${pluginId}"`);
|
|
1620
|
+
})
|
|
1621
|
+
.catch(() => { });
|
|
1622
|
+
};
|
|
1463
1623
|
const dispatch = (msg) => {
|
|
1464
1624
|
if (!clientId) {
|
|
1465
1625
|
pending.push(msg);
|
|
1466
1626
|
return;
|
|
1467
1627
|
}
|
|
1468
1628
|
const cs = service.get(clientId);
|
|
1469
|
-
if (!cs) {
|
|
1470
|
-
// Session not ready yet (hello processing) — hold the command.
|
|
1629
|
+
if (!cs || !attachDone) {
|
|
1630
|
+
// Session not ready yet (hello processing / plugin chain) — hold the command.
|
|
1471
1631
|
pending.push(msg);
|
|
1472
1632
|
return;
|
|
1473
1633
|
}
|
|
@@ -1509,11 +1669,17 @@ wss.on("connection", (ws) => {
|
|
|
1509
1669
|
void cs.listBgServers();
|
|
1510
1670
|
break;
|
|
1511
1671
|
case "new_chat":
|
|
1512
|
-
void cs.newChat(msg.preset);
|
|
1672
|
+
void cs.newChat(msg.preset, msg.ephemeral);
|
|
1513
1673
|
break;
|
|
1514
1674
|
case "edit_message":
|
|
1515
1675
|
void cs.editMessage(msg.messageId, msg.text, msg.attachments);
|
|
1516
1676
|
break;
|
|
1677
|
+
case "fork_session":
|
|
1678
|
+
void cs.forkSession?.(msg.messageId, msg.position, msg.conversationId);
|
|
1679
|
+
break;
|
|
1680
|
+
case "rollback_session":
|
|
1681
|
+
void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
|
|
1682
|
+
break;
|
|
1517
1683
|
case "cycle_model":
|
|
1518
1684
|
void cs.cycleModel();
|
|
1519
1685
|
break;
|
|
@@ -1568,6 +1734,9 @@ wss.on("connection", (ws) => {
|
|
|
1568
1734
|
case "dismiss_finished_subagents":
|
|
1569
1735
|
void cs.dismissFinishedSubagents(msg.parentId);
|
|
1570
1736
|
break;
|
|
1737
|
+
case "subagent_handoff":
|
|
1738
|
+
void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
|
|
1739
|
+
break;
|
|
1571
1740
|
case "switch_session":
|
|
1572
1741
|
void cs.switchSession(msg.path);
|
|
1573
1742
|
break;
|
|
@@ -1704,6 +1873,11 @@ wss.on("connection", (ws) => {
|
|
|
1704
1873
|
case "check_updates_all":
|
|
1705
1874
|
void cs.checkUpdatesAll(msg.force === true);
|
|
1706
1875
|
break;
|
|
1876
|
+
case "check_plugin_updates":
|
|
1877
|
+
if (typeof cs.checkPluginUpdates === "function") {
|
|
1878
|
+
void cs.checkPluginUpdates(true);
|
|
1879
|
+
}
|
|
1880
|
+
break;
|
|
1707
1881
|
case "restart_service": {
|
|
1708
1882
|
// Same effect as `pi-web-ui server restart`: this process exits and its
|
|
1709
1883
|
// supervisor brings it back (launchd/systemd immediately, the Windows
|
|
@@ -1780,7 +1954,10 @@ wss.on("connection", (ws) => {
|
|
|
1780
1954
|
void cs.listProviders();
|
|
1781
1955
|
break;
|
|
1782
1956
|
case "fetch_models":
|
|
1783
|
-
void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
|
|
1957
|
+
void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
|
|
1958
|
+
break;
|
|
1959
|
+
case "test_model_connection":
|
|
1960
|
+
void cs.testModelConnection?.(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
|
|
1784
1961
|
break;
|
|
1785
1962
|
case "refresh_provider_models":
|
|
1786
1963
|
void cs.refreshProviderModels(msg.providerId, msg.reqId);
|
|
@@ -1887,8 +2064,10 @@ wss.on("connection", (ws) => {
|
|
|
1887
2064
|
terminalToolsEnabled: msg.terminalToolsEnabled,
|
|
1888
2065
|
terminalBash: msg.terminalBash,
|
|
1889
2066
|
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
2067
|
+
terminalBashMaxForegroundMs: msg.terminalBashMaxForegroundMs,
|
|
1890
2068
|
toolWatchdogTimeoutMs: msg.toolWatchdogTimeoutMs,
|
|
1891
2069
|
readDirEnabled: msg.readDirEnabled,
|
|
2070
|
+
toolApprovalEnabled: msg.toolApprovalEnabled,
|
|
1892
2071
|
editSoftEnabled: msg.editSoftEnabled,
|
|
1893
2072
|
questionnaireEnabled: msg.questionnaireEnabled,
|
|
1894
2073
|
parallelReminderEnabled: msg.parallelReminderEnabled,
|
|
@@ -1963,49 +2142,112 @@ wss.on("connection", (ws) => {
|
|
|
1963
2142
|
const jobLang = () => cs?.getLang() ?? "en";
|
|
1964
2143
|
const jobId = String(msg.jobId ?? "");
|
|
1965
2144
|
const pluginId = String(msg.id ?? "");
|
|
1966
|
-
|
|
2145
|
+
// function 声明会提升、TS 对 msg 判别联合的收窄进不了闭包 —— 先拍平成常量。
|
|
2146
|
+
const jobAction = msg.action;
|
|
2147
|
+
const jobSpec = {
|
|
1967
2148
|
jobId,
|
|
1968
|
-
action:
|
|
2149
|
+
action: jobAction,
|
|
1969
2150
|
id: pluginId,
|
|
1970
2151
|
source: msg.source,
|
|
1971
2152
|
build: msg.build === true,
|
|
1972
2153
|
noBuild: msg.noBuild === true,
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
emit: (m) => send(m),
|
|
1976
|
-
done: async (ok, info) => {
|
|
1977
|
-
if (ok) {
|
|
1978
|
-
await reloadPluginsAndPush(jobLang);
|
|
1979
|
-
}
|
|
1980
|
-
else if (info.error) {
|
|
1981
|
-
cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
|
|
1982
|
-
}
|
|
1983
|
-
},
|
|
1984
|
-
});
|
|
1985
|
-
if (!started.ok) {
|
|
1986
|
-
// 被拒(忙 / 托管实例 / 参数非法)也要回一条 done,让面板上的作业就地结束。
|
|
2154
|
+
};
|
|
2155
|
+
const jobDone = (ok, error) => {
|
|
1987
2156
|
send({
|
|
1988
2157
|
type: "plugin_job",
|
|
1989
2158
|
jobId,
|
|
1990
|
-
action:
|
|
2159
|
+
action: jobAction,
|
|
1991
2160
|
pluginId,
|
|
1992
2161
|
phase: "done",
|
|
1993
|
-
ok
|
|
1994
|
-
error:
|
|
2162
|
+
ok,
|
|
2163
|
+
...(error ? { error } : {}),
|
|
1995
2164
|
output: "",
|
|
1996
2165
|
});
|
|
2166
|
+
};
|
|
2167
|
+
// 参数静态校验:参数非法直接拒绝,避免向客户端发起无意义/恶意的确认弹窗。
|
|
2168
|
+
const argCheck = buildPluginJobArgs(jobSpec, DATA_DIR, jobLang);
|
|
2169
|
+
if ("error" in argCheck) {
|
|
2170
|
+
jobDone(false, argCheck.error);
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
// 安装确认门(P0):install/update 先经用户确认。第三方页面脚本可以直发
|
|
2174
|
+
// plugin_job;拒绝/超时直接回一条 done,让面板上的作业就地结束(不占
|
|
2175
|
+
// 安装锁、不弹「失败」之外的噪音)。卸载不在本门范围内(由面板本身发起)。
|
|
2176
|
+
if (jobAction === "install" || jobAction === "update") {
|
|
2177
|
+
void confirmPluginInstall([{ id: pluginId, source: String(msg.source ?? "") }])
|
|
2178
|
+
.then((confirmed) => {
|
|
2179
|
+
if (!confirmed) {
|
|
2180
|
+
jobDone(false, "用户未确认安装(拒绝或 120 秒超时)");
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
startPluginJob();
|
|
2184
|
+
})
|
|
2185
|
+
.catch(() => jobDone(false, "安装确认流程异常"));
|
|
2186
|
+
break;
|
|
2187
|
+
}
|
|
2188
|
+
startPluginJob();
|
|
2189
|
+
// 真正派发作业(确认门通过后走这里):被拒(忙 / 托管实例 / 参数非法)也要回
|
|
2190
|
+
// 一条 done,让面板上的作业就地结束。
|
|
2191
|
+
function startPluginJob() {
|
|
2192
|
+
const started = pluginInstaller.start(jobSpec, {
|
|
2193
|
+
lang: jobLang,
|
|
2194
|
+
emit: (m) => send(m),
|
|
2195
|
+
done: async (ok, info) => {
|
|
2196
|
+
if (ok) {
|
|
2197
|
+
await reloadPluginsAndPush(jobLang);
|
|
2198
|
+
}
|
|
2199
|
+
else if (info.error) {
|
|
2200
|
+
cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
});
|
|
2204
|
+
if (!started.ok)
|
|
2205
|
+
jobDone(false, started.error);
|
|
1997
2206
|
}
|
|
1998
2207
|
break;
|
|
1999
2208
|
}
|
|
2000
2209
|
case "plugin_job_cancel":
|
|
2001
2210
|
pluginInstaller.cancel(String(msg.jobId ?? ""));
|
|
2002
2211
|
break;
|
|
2212
|
+
// 注册面目录(DSH P2-7):只读装配(slot/工具/宿主方法表+当前占用者),按需拉取。
|
|
2213
|
+
case "plugin_api_catalog": {
|
|
2214
|
+
const requestId = String(msg.requestId ?? "");
|
|
2215
|
+
send({ type: "plugin_api_catalog_result", requestId, catalog: pluginMgr.getApiCatalog() });
|
|
2216
|
+
break;
|
|
2217
|
+
}
|
|
2218
|
+
// 安装前先读 spec(DSH P0-3):不联网也能查(形状/已装),GitHub 源再探一次
|
|
2219
|
+
// raw manifest。结果只作引导(UI 把 problem 渲染成输入框下的一句话),不阻塞安装。
|
|
2220
|
+
case "plugin_install_inspect": {
|
|
2221
|
+
const requestId = String(msg.requestId ?? "");
|
|
2222
|
+
const source = String(msg.source ?? "");
|
|
2223
|
+
void inspectInstallSpec(source, {
|
|
2224
|
+
pluginsDir: pluginMgr.pluginsDir,
|
|
2225
|
+
...(typeof msg.explicitId === "string" && msg.explicitId.trim() ? { explicitId: msg.explicitId.trim() } : {}),
|
|
2226
|
+
force: msg.force === true,
|
|
2227
|
+
}).then((r) => {
|
|
2228
|
+
send({
|
|
2229
|
+
type: "plugin_install_inspect_result",
|
|
2230
|
+
requestId,
|
|
2231
|
+
source,
|
|
2232
|
+
kind: r.spec.kind,
|
|
2233
|
+
suggestedId: r.suggestedId,
|
|
2234
|
+
installed: r.installed,
|
|
2235
|
+
...(r.problem ? { problem: r.problem } : {}),
|
|
2236
|
+
...(r.detail ? { detail: r.detail } : {}),
|
|
2237
|
+
...(r.manifest ? { manifest: r.manifest } : {}),
|
|
2238
|
+
});
|
|
2239
|
+
});
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2003
2242
|
// -- 插件目录授权(issue #146)------------------------------------------
|
|
2004
2243
|
case "plugin_path_response": {
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2244
|
+
const id = String(msg.id ?? "");
|
|
2245
|
+
const pending = pendingPathRequests.get(id);
|
|
2246
|
+
// 来源绑定:应答必须来自弹窗广播时在线的 clientId——广播后才连上的
|
|
2247
|
+
// 端没见过弹窗,忽略其代答(pending 保留,真正的弹窗端仍可答复)。
|
|
2248
|
+
if (pending && clientId && pending.recipients.has(clientId)) {
|
|
2007
2249
|
clearTimeout(pending.timer);
|
|
2008
|
-
pendingPathRequests.delete(
|
|
2250
|
+
pendingPathRequests.delete(id);
|
|
2009
2251
|
pending.resolve(msg.ok === true);
|
|
2010
2252
|
}
|
|
2011
2253
|
break;
|
|
@@ -2014,7 +2256,8 @@ wss.on("connection", (ws) => {
|
|
|
2014
2256
|
case "plugin_permission_response": {
|
|
2015
2257
|
const id = String(msg.id ?? "");
|
|
2016
2258
|
const pending = pendingPermissionRequests.get(id);
|
|
2017
|
-
|
|
2259
|
+
// 来源绑定:同 plugin_path_response——只有收到弹窗广播的端可代答。
|
|
2260
|
+
if (pending && clientId && pending.recipients.has(clientId)) {
|
|
2018
2261
|
clearTimeout(pending.timer);
|
|
2019
2262
|
pendingPermissionRequests.delete(id);
|
|
2020
2263
|
// 先答复者胜:通知其它在线端收起同一条请求(与目录授权不同,这里要显式 resolved)。
|
|
@@ -2034,19 +2277,49 @@ wss.on("connection", (ws) => {
|
|
|
2034
2277
|
break;
|
|
2035
2278
|
}
|
|
2036
2279
|
case "plugin_dom_consent": {
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2280
|
+
// grant 是提权方向:走两步握手——只生成在途 consent 请求并广播,不在此
|
|
2281
|
+
// 处落盘;收到绑定来源的 plugin_dom_consent_response 后才 setDomConsent。
|
|
2282
|
+
// 同 pluginId 已有在途请求时忽略(首个优先,120s 超时自动失效)。
|
|
2283
|
+
if (msg.granted === true) {
|
|
2284
|
+
const pid = typeof msg.pluginId === "string" ? msg.pluginId.trim() : "";
|
|
2285
|
+
if (!pid || !pluginMgr.isDomPlugin(pid) || pendingDomConsents.has(pid))
|
|
2286
|
+
break;
|
|
2287
|
+
const id = randomUUID();
|
|
2288
|
+
const timer = setTimeout(() => {
|
|
2289
|
+
pendingDomConsents.delete(pid);
|
|
2290
|
+
}, 120_000);
|
|
2291
|
+
pendingDomConsents.set(pid, { id, pluginId: pid, recipients: new Set(pluginMgr.onlineClientIds()), timer });
|
|
2292
|
+
// from = 发起端 clientId:前端只自动应答自己发起的授权(设置面板点击)。
|
|
2293
|
+
const payload = JSON.stringify({ type: "plugin_dom_consent_request", id, pluginId: pid, from: clientId });
|
|
2294
|
+
for (const client of wss.clients) {
|
|
2295
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2296
|
+
try {
|
|
2297
|
+
client.send(payload);
|
|
2298
|
+
}
|
|
2299
|
+
catch {
|
|
2300
|
+
/* 死连接 */
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
break;
|
|
2305
|
+
}
|
|
2306
|
+
// revoke 是降权方向:单步直达(不在途等待),pluginId 校验交给 setDomConsent。
|
|
2307
|
+
applyDomConsent(msg.pluginId, false);
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
// -- DOM 授权两步握手的应答:按 id 回查在途请求,且应答连接必须在弹窗
|
|
2311
|
+
// 广播时的在线端集合里,才允许把 grant 持久写盘。
|
|
2312
|
+
case "plugin_dom_consent_response": {
|
|
2313
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2314
|
+
const pendingConsent = findDomConsentById(id);
|
|
2315
|
+
if (pendingConsent && clientId && pendingConsent.recipients.has(clientId)) {
|
|
2316
|
+
clearTimeout(pendingConsent.timer);
|
|
2317
|
+
pendingDomConsents.delete(pendingConsent.pluginId);
|
|
2318
|
+
// ok=false = 显式拒绝:只清在途请求不落盘(当前前端自动应答只发
|
|
2319
|
+
// true,这里守住协议语义,将来接拒绝按钮不用动服务端)。
|
|
2320
|
+
if (msg.ok === true)
|
|
2321
|
+
applyDomConsent(pendingConsent.pluginId, true);
|
|
2322
|
+
}
|
|
2050
2323
|
break;
|
|
2051
2324
|
}
|
|
2052
2325
|
case "plugin_path_revoke": {
|
|
@@ -2075,9 +2348,17 @@ wss.on("connection", (ws) => {
|
|
|
2075
2348
|
customCatalogPath: pluginMgr.customCatalogPath,
|
|
2076
2349
|
pluginsDir: join(DATA_DIR, "plugins"),
|
|
2077
2350
|
installer: pluginInstaller,
|
|
2078
|
-
|
|
2351
|
+
// 只更新市场列表时无需重启已激活插件,避免重复广播工作目录。
|
|
2352
|
+
afterWrite: () => (msg.install === true ? reloadPluginsAndPush(syncLang) : pluginMgr.pushCatalog()),
|
|
2079
2353
|
lang: syncLang,
|
|
2354
|
+
// 本地文件来源只允许工作区内(防任意路径文件探测 oracle)。
|
|
2355
|
+
workspaceRoot: cs?.cwd ?? CWD,
|
|
2356
|
+
// 安装确认门(P0):拒绝/超时只写目录不安装。
|
|
2357
|
+
confirmInstall: (items) => confirmPluginInstall(items),
|
|
2080
2358
|
}).then((r) => {
|
|
2359
|
+
if (r.installRefused) {
|
|
2360
|
+
cs?.emitNotice("warning", "目录已同步,但安装未获用户确认(拒绝或超时),未安装任何插件", "Catalog synced, but installation was not confirmed (denied or timed out) — nothing was installed");
|
|
2361
|
+
}
|
|
2081
2362
|
send({
|
|
2082
2363
|
type: "plugin_catalog_sync_result",
|
|
2083
2364
|
requestId,
|
|
@@ -2129,6 +2410,19 @@ wss.on("connection", (ws) => {
|
|
|
2129
2410
|
void cs.answerQuestion?.(msg.id, msg.answers, msg.cancelled);
|
|
2130
2411
|
}
|
|
2131
2412
|
break;
|
|
2413
|
+
case "tool_approval_response":
|
|
2414
|
+
cs.resolveToolApproval?.(msg.id, msg.decision, msg.editedParams, msg.reason, msg.scope);
|
|
2415
|
+
break;
|
|
2416
|
+
case "set_approval_policy":
|
|
2417
|
+
cs.setApprovalPolicy?.({
|
|
2418
|
+
conversationId: msg.conversationId,
|
|
2419
|
+
allowAll: msg.allowAll,
|
|
2420
|
+
categories: msg.categories,
|
|
2421
|
+
});
|
|
2422
|
+
break;
|
|
2423
|
+
case "plan_update":
|
|
2424
|
+
cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
|
|
2425
|
+
break;
|
|
2132
2426
|
case "page_response":
|
|
2133
2427
|
// 浏览器(page-picker 扩展经前端)对 browser_page 的回包:恢复挂起的
|
|
2134
2428
|
// pageCall;id 不匹配(超时后迟到/页面刷新)由 resolvePageCall 静默忽略。
|
|
@@ -2143,6 +2437,18 @@ wss.on("connection", (ws) => {
|
|
|
2143
2437
|
case "delete_subagent_template":
|
|
2144
2438
|
void cs.deleteSubagentTemplate(msg.name);
|
|
2145
2439
|
break;
|
|
2440
|
+
case "save_approval_rule":
|
|
2441
|
+
void cs.saveApprovalRule?.(msg.rule);
|
|
2442
|
+
break;
|
|
2443
|
+
case "save_approval_rules":
|
|
2444
|
+
void cs.saveApprovalRules?.(msg.rules);
|
|
2445
|
+
break;
|
|
2446
|
+
case "delete_approval_rule":
|
|
2447
|
+
void cs.deleteApprovalRule?.(msg.id);
|
|
2448
|
+
break;
|
|
2449
|
+
case "reset_builtin_approval_rule":
|
|
2450
|
+
void cs.resetBuiltinApprovalRule?.(msg.id);
|
|
2451
|
+
break;
|
|
2146
2452
|
case "apply_preset":
|
|
2147
2453
|
void cs.applyPreset(msg.name);
|
|
2148
2454
|
break;
|
|
@@ -2218,6 +2524,22 @@ wss.on("connection", (ws) => {
|
|
|
2218
2524
|
}
|
|
2219
2525
|
}
|
|
2220
2526
|
};
|
|
2527
|
+
/** ready 握手帧:首连与重复 hello 的幂等回包共用同一构造,避免两处漂移。 */
|
|
2528
|
+
const readyMsg = (cid) => ({
|
|
2529
|
+
type: "ready",
|
|
2530
|
+
clientId: cid,
|
|
2531
|
+
serverVersion: VERSION,
|
|
2532
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2533
|
+
engine: ENGINE,
|
|
2534
|
+
// This package's own version. `serverVersion` is the pi SDK's,
|
|
2535
|
+
// and the client used to learn ours from the update check —
|
|
2536
|
+
// which a managed instance never runs.
|
|
2537
|
+
appVersion: appVersion(),
|
|
2538
|
+
buildId: buildId(),
|
|
2539
|
+
managed: MANAGED,
|
|
2540
|
+
tabs: TABS ? [...TABS] : undefined,
|
|
2541
|
+
service: SERVICE_INFO ?? undefined,
|
|
2542
|
+
});
|
|
2221
2543
|
ws.on("message", (data) => {
|
|
2222
2544
|
let msg;
|
|
2223
2545
|
try {
|
|
@@ -2227,28 +2549,43 @@ wss.on("connection", (ws) => {
|
|
|
2227
2549
|
return;
|
|
2228
2550
|
}
|
|
2229
2551
|
if (msg.type === "hello") {
|
|
2230
|
-
|
|
2552
|
+
// 重放守卫:一条连接只允许 attach 一次。clientId 已赋值说明 hello 处理过
|
|
2553
|
+
//(或进行中),再来的 hello 幂等回一条 ready 即可——否则重复 hello 会把
|
|
2554
|
+
// service.attach 整个再跑一遍:重型 ClientSession 重复创建 + 旧 send sink
|
|
2555
|
+
// 永久泄漏(removePluginSender 只在 close 时清一次)。
|
|
2556
|
+
if (clientId) {
|
|
2557
|
+
if (!closed)
|
|
2558
|
+
send(readyMsg(clientId));
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
// clientId 校验:外部输入,类型/长度/字符集不合格直接换 randomUUID()——
|
|
2562
|
+
// 它随后成为 ClientSession key 与上传落盘目录名(uploads/<clientId>/,
|
|
2563
|
+
// saveUpload 的两条路径都从这里来,入口统一拦一次即可)。
|
|
2564
|
+
const cid = validateClientId(msg.clientId) ?? randomUUID();
|
|
2231
2565
|
clientId = cid;
|
|
2566
|
+
// issue #295:ready 先行 —— 传输握手不等待会话初始化。attach 会进 SDK 的
|
|
2567
|
+
// resourceLoader.reload 等同步目录扫描,坏挂载/家目录下可能阻塞数十秒;
|
|
2568
|
+
// ready 在握手里先发,前端立刻离开「正在连接」(快照随后到)。
|
|
2569
|
+
if (!closed) {
|
|
2570
|
+
send(readyMsg(cid));
|
|
2571
|
+
}
|
|
2572
|
+
// attach 慢提示:本体继续等,不取消;完成后照常走快照流程。
|
|
2573
|
+
const slowTimer = setTimeout(() => {
|
|
2574
|
+
if (closed)
|
|
2575
|
+
return;
|
|
2576
|
+
send({
|
|
2577
|
+
type: "notice",
|
|
2578
|
+
level: "warning",
|
|
2579
|
+
text: "会话初始化耗时较长(可能在扫描工作区目录),请稍候…",
|
|
2580
|
+
textEn: "Session init is taking a while (possibly scanning the workspace) — hang on…",
|
|
2581
|
+
});
|
|
2582
|
+
}, ATTACH_SLOW_NOTICE_MS);
|
|
2232
2583
|
service
|
|
2233
2584
|
.attach(cid, send)
|
|
2234
2585
|
.then((cs) => {
|
|
2586
|
+
clearTimeout(slowTimer);
|
|
2235
2587
|
if (closed)
|
|
2236
2588
|
return;
|
|
2237
|
-
send({
|
|
2238
|
-
type: "ready",
|
|
2239
|
-
clientId: cid,
|
|
2240
|
-
serverVersion: VERSION,
|
|
2241
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
2242
|
-
engine: ENGINE,
|
|
2243
|
-
// This package's own version. `serverVersion` is the pi SDK's,
|
|
2244
|
-
// and the client used to learn ours from the update check —
|
|
2245
|
-
// which a managed instance never runs.
|
|
2246
|
-
appVersion: appVersion(),
|
|
2247
|
-
buildId: buildId(),
|
|
2248
|
-
managed: MANAGED,
|
|
2249
|
-
tabs: TABS ? [...TABS] : undefined,
|
|
2250
|
-
service: SERVICE_INFO ?? undefined,
|
|
2251
|
-
});
|
|
2252
2589
|
// Plugin catalog: re-scan + activate new dirs on every attach so
|
|
2253
2590
|
// freshly dropped plugins show up without a server restart.
|
|
2254
2591
|
pluginMgr
|
|
@@ -2284,6 +2621,7 @@ wss.on("connection", (ws) => {
|
|
|
2284
2621
|
catch {
|
|
2285
2622
|
/* 推送失败不挡快照 */
|
|
2286
2623
|
}
|
|
2624
|
+
replayQueued();
|
|
2287
2625
|
})
|
|
2288
2626
|
.catch(() => {
|
|
2289
2627
|
if (closed)
|
|
@@ -2291,31 +2629,66 @@ wss.on("connection", (ws) => {
|
|
|
2291
2629
|
// ensureLoaded 失败(如磁盘读错)不能卡死快照——前端 30s 无消息
|
|
2292
2630
|
// 会重连,重连又失败会陷入循环。至少把状态推下去。
|
|
2293
2631
|
cs.flushSnapshot();
|
|
2632
|
+
replayQueued();
|
|
2294
2633
|
});
|
|
2295
2634
|
// hello may carry the UI locale — persist it before replaying
|
|
2296
2635
|
// anything queued during startup (issue #91).
|
|
2297
2636
|
if (msg.locale)
|
|
2298
2637
|
void service.setLocale(cid, msg.locale);
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2638
|
+
// attach 期间收到的命令先排队(dispatch 里的 pending),必须等插件链
|
|
2639
|
+
// 就绪后再重放:prompt 里可能是插件命令(/probe-grant 等),目录由
|
|
2640
|
+
// 下面的 applyPluginCommandCatalog 同步;提前重放会撞上「未知命令」。
|
|
2641
|
+
// ready 先行(issue #295)之前,客户端收到 ready 时 attach 已完成,
|
|
2642
|
+
// 首条命令天然落在插件加载之后;现在 ready 与 attach 脱钩,不等就重放
|
|
2643
|
+
// 等于把竞态窗口从一个 RTT 放大到整个插件扫描期(CI 必现 grant 超时)。
|
|
2644
|
+
const replayQueued = () => {
|
|
2645
|
+
attachDone = true;
|
|
2646
|
+
for (const m of pending.drain())
|
|
2647
|
+
dispatch(m);
|
|
2648
|
+
};
|
|
2304
2649
|
})
|
|
2305
2650
|
.catch((err) => {
|
|
2306
2651
|
// Admission refused (quiesce): close the socket so the browser
|
|
2307
2652
|
// reconnect loop keeps retrying until admission reopens. Do NOT
|
|
2308
2653
|
// leave a half-alive connection that can only show an error.
|
|
2654
|
+
clearTimeout(slowTimer);
|
|
2309
2655
|
if (err instanceof QuiesceRejectedError) {
|
|
2310
2656
|
closed = true;
|
|
2311
2657
|
if (ws.readyState === WebSocket.OPEN) {
|
|
2312
|
-
|
|
2658
|
+
// 先发 4403 关闭帧,等它刷出去再真正撕连接:close 后立刻
|
|
2659
|
+
// terminate 会把关闭握手一起掐掉,客户端只能看到 1006
|
|
2660
|
+
// (quiesce-test 的 brand-new client 断言 4403)。
|
|
2661
|
+
try {
|
|
2662
|
+
ws.close(4403, "quiesced");
|
|
2663
|
+
}
|
|
2664
|
+
catch {
|
|
2665
|
+
/* already closing */
|
|
2666
|
+
}
|
|
2667
|
+
setTimeout(() => {
|
|
2668
|
+
try {
|
|
2669
|
+
if (ws.readyState !== WebSocket.CLOSED)
|
|
2670
|
+
ws.terminate?.();
|
|
2671
|
+
}
|
|
2672
|
+
catch {
|
|
2673
|
+
/* ignore */
|
|
2674
|
+
}
|
|
2675
|
+
}, 500);
|
|
2676
|
+
}
|
|
2677
|
+
else {
|
|
2678
|
+
try {
|
|
2679
|
+
ws.terminate?.();
|
|
2680
|
+
}
|
|
2681
|
+
catch {
|
|
2682
|
+
/* ignore */
|
|
2683
|
+
}
|
|
2313
2684
|
}
|
|
2314
|
-
ws.terminate?.();
|
|
2315
2685
|
return;
|
|
2316
2686
|
}
|
|
2317
2687
|
// Real init failure (bad agent dir etc.) — keep the connection
|
|
2318
2688
|
// open so the user can see the error and fix it.
|
|
2689
|
+
// 排队的命令此时无会话可服务,直接丢弃(否则 attachDone 永 false,
|
|
2690
|
+
// 队列越积越深;用户修好后重连会重发 get_state)。
|
|
2691
|
+
pending.clear();
|
|
2319
2692
|
send({
|
|
2320
2693
|
type: "notice",
|
|
2321
2694
|
level: "error",
|
|
@@ -2330,7 +2703,7 @@ wss.on("connection", (ws) => {
|
|
|
2330
2703
|
ws.on("close", () => {
|
|
2331
2704
|
service.noteSocketClose();
|
|
2332
2705
|
closed = true;
|
|
2333
|
-
pending
|
|
2706
|
+
pending.clear();
|
|
2334
2707
|
removePluginSender();
|
|
2335
2708
|
if (snapshotRetryTimer) {
|
|
2336
2709
|
clearTimeout(snapshotRetryTimer);
|
|
@@ -2371,11 +2744,12 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
2371
2744
|
console.log(` session dir : ${SESSION_DIR_ROOT}`);
|
|
2372
2745
|
console.log(` pi SDK : v${VERSION}`);
|
|
2373
2746
|
// issue #260:全局那份 pi SDK 不是服务在用的那份(自带副本赢在 Node 解析顺序上)。
|
|
2374
|
-
//
|
|
2747
|
+
// #321 起默认已反转(resolve-global-sdk 自动跟随更新的那份),这条提示只在
|
|
2748
|
+
// 「进程没跟上」(升级发生在启动后 / 旧构建没注入钩子)或显式 PI_WEB_SDK=bundled
|
|
2749
|
+
// 钉死自带副本时出现。
|
|
2375
2750
|
const sdkNote = sdkOriginNote(sdkCopies(), VERSION);
|
|
2376
2751
|
if (sdkNote) {
|
|
2377
2752
|
console.log(` pi SDK note : ${sdkNote}`);
|
|
2378
|
-
console.log(` Upgrading the global pi CLI does not change this server — upgrade pi-web-ui instead.`);
|
|
2379
2753
|
}
|
|
2380
2754
|
console.log(` bind : ${HOST}:${PORT}`);
|
|
2381
2755
|
console.log("");
|
|
@@ -2398,7 +2772,8 @@ if (!bootCatalogDisabled) {
|
|
|
2398
2772
|
customCatalogPath: pluginMgr.customCatalogPath,
|
|
2399
2773
|
pluginsDir: join(DATA_DIR, "plugins"),
|
|
2400
2774
|
installer: pluginInstaller,
|
|
2401
|
-
|
|
2775
|
+
// 默认仅同步市场列表;重载插件会重复触发其激活广播。
|
|
2776
|
+
afterWrite: () => (autoInstall ? reloadPluginsAndPush() : pluginMgr.pushCatalog()),
|
|
2402
2777
|
}).then((r) => {
|
|
2403
2778
|
if (!r.ok) {
|
|
2404
2779
|
console.warn(`[catalog] 插件目录预同步失败(不阻断启动): ${r.error}`);
|
|
@@ -2485,6 +2860,7 @@ async function shutdown(signal = "SIGINT") {
|
|
|
2485
2860
|
pluginInstaller.dispose();
|
|
2486
2861
|
mcpHotReload.dispose();
|
|
2487
2862
|
mcpBridge.dispose();
|
|
2863
|
+
await globalLspPool.shutdownAll();
|
|
2488
2864
|
await service.disposeAll();
|
|
2489
2865
|
// Don't let dead browsers hold the exit open: half-open WebSocket /
|
|
2490
2866
|
// keep-alive HTTP connections (e.g. test clients killed without
|