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
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,6 +32,7 @@ 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";
|
|
36
37
|
import { initAttachmentStore, readAttachment } from "./attachment-store.js";
|
|
37
38
|
import { isAudioFile, previewKind } from "./text-sniff.js";
|
|
@@ -43,9 +44,11 @@ import { isManaged, managedRefusal } from "./managed.js";
|
|
|
43
44
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
44
45
|
import { parseTabs, tabsRefusal } from "./tabs.js";
|
|
45
46
|
import { recordUnknownWsType } from "./ws-unknown-types.js";
|
|
47
|
+
import { validateClientId } from "./ws-client-id.js";
|
|
48
|
+
import { PendingCommandQueue } from "./ws-pending-queue.js";
|
|
46
49
|
import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
|
|
47
50
|
import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
|
|
48
|
-
import { inspectInstallSpec, PluginInstaller } from "./plugin-installer.js";
|
|
51
|
+
import { buildPluginJobArgs, confirmPluginInstall, inspectInstallSpec, PluginInstaller } from "./plugin-installer.js";
|
|
49
52
|
import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
50
53
|
import { McpBridge } from "./mcp-bridge.js";
|
|
51
54
|
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
@@ -203,6 +206,19 @@ if (process.platform === "win32") {
|
|
|
203
206
|
void ensureWindowsBash();
|
|
204
207
|
}
|
|
205
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
|
+
});
|
|
206
222
|
app.use(express.json({ limit: "10mb" }));
|
|
207
223
|
/** 从请求中提取候选 token:头 / 查询参数 / cookie(浏览器导航场景靠 cookie 续命)。 */
|
|
208
224
|
function requestTokens(req) {
|
|
@@ -240,8 +256,15 @@ function requestTokens(req) {
|
|
|
240
256
|
}
|
|
241
257
|
return out.filter(Boolean);
|
|
242
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
|
+
}
|
|
243
266
|
function tokenOk(req) {
|
|
244
|
-
return requestTokens(req).
|
|
267
|
+
return requestTokens(req).some(sameSecret);
|
|
245
268
|
}
|
|
246
269
|
/** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
|
|
247
270
|
* 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
|
|
@@ -283,7 +306,7 @@ if (AUTH_TOKEN) {
|
|
|
283
306
|
// cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
|
|
284
307
|
// 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
|
|
285
308
|
// 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
|
|
286
|
-
if (cookie
|
|
309
|
+
if (!sameSecret(cookie)) {
|
|
287
310
|
res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
|
|
288
311
|
}
|
|
289
312
|
}
|
|
@@ -320,14 +343,14 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
|
|
|
320
343
|
const TABS = parseTabs();
|
|
321
344
|
registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
|
|
322
345
|
app.get("/api/health", (_req, res) => {
|
|
346
|
+
// 审查 #352:该端点对未鉴权开放(监控探针需要),故只保留版本/引擎与 SDK
|
|
347
|
+
// 副本诊断信息,不再暴露 cwd(工作区路径披露)与 pid(指纹/信息收集面)。
|
|
323
348
|
res.json({
|
|
324
349
|
ok: true,
|
|
325
350
|
piVersion: VERSION,
|
|
326
351
|
// issue #260:服务实际加载的是自带副本,不是全局 pi CLI 那份。这里把两份都报出来,
|
|
327
352
|
// 用户就不用猜「为什么升了全局 SDK 不生效」。(纯新增字段,piVersion 语义不变。)
|
|
328
353
|
piSdkCopies: sdkCopies(),
|
|
329
|
-
cwd: CWD,
|
|
330
|
-
pid: process.pid,
|
|
331
354
|
engine: ENGINE,
|
|
332
355
|
});
|
|
333
356
|
});
|
|
@@ -346,6 +369,15 @@ app.get("/api/attachment/:hash", async (req, res) => {
|
|
|
346
369
|
res.setHeader("Content-Type", hit.mimeType);
|
|
347
370
|
res.setHeader("Content-Length", hit.buffer.length);
|
|
348
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
|
+
}
|
|
349
381
|
res.end(hit.buffer);
|
|
350
382
|
}
|
|
351
383
|
catch (err) {
|
|
@@ -412,6 +444,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
412
444
|
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
|
|
413
445
|
// 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
|
|
414
446
|
// 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
|
|
447
|
+
// 注:绝对 wire 路径分支是机器浏览设计(whole-machine browsing),
|
|
448
|
+
// 不受工作区约束——别被注释误导(审查 #352)。
|
|
415
449
|
res.download(abs, name, { dotfiles: "allow" });
|
|
416
450
|
}
|
|
417
451
|
else {
|
|
@@ -429,6 +463,12 @@ app.get("/api/file", async (req, res) => {
|
|
|
429
463
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
430
464
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
431
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
|
+
}
|
|
432
472
|
res.sendFile(abs, { dotfiles: "allow" });
|
|
433
473
|
}
|
|
434
474
|
}
|
|
@@ -493,6 +533,10 @@ app.get("/api/preview/*splat", async (req, res) => {
|
|
|
493
533
|
const allowJs = req.query.allowJs === "1";
|
|
494
534
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
495
535
|
}
|
|
536
|
+
else if (lower.endsWith(".svg")) {
|
|
537
|
+
// 与 /api/attachment 同理:SVG 文档形态的 <script> 沙箱化(审查 #352 P0-1)。
|
|
538
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
539
|
+
}
|
|
496
540
|
res.sendFile(abs, { dotfiles: "allow" });
|
|
497
541
|
}
|
|
498
542
|
catch {
|
|
@@ -810,8 +854,13 @@ function parseAuthority(a) {
|
|
|
810
854
|
}
|
|
811
855
|
}
|
|
812
856
|
function originAllowed(req) {
|
|
813
|
-
const hostHeader =
|
|
814
|
-
|
|
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());
|
|
815
864
|
if (ALLOW_HOSTS.length > 0 && !ALLOW_HOSTS.includes(host.hostname)) {
|
|
816
865
|
return false;
|
|
817
866
|
}
|
|
@@ -990,7 +1039,9 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
|
|
|
990
1039
|
pendingPathRequests.delete(id);
|
|
991
1040
|
resolve(false);
|
|
992
1041
|
}, 120_000);
|
|
993
|
-
|
|
1042
|
+
// 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
|
|
1043
|
+
const recipients = new Set(pluginMgr.onlineClientIds());
|
|
1044
|
+
pendingPathRequests.set(id, { resolve, timer, recipients });
|
|
994
1045
|
const payload = JSON.stringify({
|
|
995
1046
|
type: "plugin_path_request",
|
|
996
1047
|
id,
|
|
@@ -1043,7 +1094,9 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
1043
1094
|
}
|
|
1044
1095
|
resolve({ ok: false, remember: false });
|
|
1045
1096
|
}, 120_000);
|
|
1046
|
-
|
|
1097
|
+
// 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
|
|
1098
|
+
const recipients = new Set(pluginMgr.onlineClientIds());
|
|
1099
|
+
pendingPermissionRequests.set(id, { resolve, timer, recipients });
|
|
1047
1100
|
const ask = JSON.stringify({
|
|
1048
1101
|
type: "plugin_permission_request",
|
|
1049
1102
|
id,
|
|
@@ -1065,6 +1118,26 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
1065
1118
|
}
|
|
1066
1119
|
});
|
|
1067
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
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
async function confirmPluginInstallHelper(items) {
|
|
1135
|
+
return confirmPluginInstall(items, {
|
|
1136
|
+
permGrants: pluginMgr.permGrants,
|
|
1137
|
+
permissionRequester: pluginMgr.permissionRequester,
|
|
1138
|
+
onGrantsChanged: pushPluginPermissions,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1068
1141
|
// 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
|
|
1069
1142
|
// client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
|
|
1070
1143
|
// wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
|
|
@@ -1466,8 +1539,13 @@ wss.on("connection", (ws) => {
|
|
|
1466
1539
|
let closed = false;
|
|
1467
1540
|
/** 最近一份全量 snapshot 的估算字节数(UTF-16 ×2),供背压相对阈值用(issue #11)。 */
|
|
1468
1541
|
let lastSnapshotBytes = 0;
|
|
1469
|
-
/** Commands received while the session is still being created — replayed after attach.
|
|
1470
|
-
|
|
1542
|
+
/** Commands received while the session is still being created — replayed after attach.
|
|
1543
|
+
* 带上限(256 条):attach 挂死/失败保活期间队列不再无界增长,超限丢最旧并告警。 */
|
|
1544
|
+
const pending = new PendingCommandQueue();
|
|
1545
|
+
/** attach 完成(含插件链 + 首快照)前一律排队(见 hello 分支的 replayQueued):
|
|
1546
|
+
* ready 先行后,ready 只代表传输通,插件命令目录/首快照都还没好,直接分发
|
|
1547
|
+
* 会撞「未知命令」/ rev 链断裂。 */
|
|
1548
|
+
let attachDone = false;
|
|
1471
1549
|
/** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
|
|
1472
1550
|
let snapshotRetryTimer = null;
|
|
1473
1551
|
// 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
|
|
@@ -1518,14 +1596,30 @@ wss.on("connection", (ws) => {
|
|
|
1518
1596
|
// Plugins broadcast to every open socket; unregister on close below. The
|
|
1519
1597
|
// cid getter lets plugins target THIS socket via host.sendTo(clientId).
|
|
1520
1598
|
const removePluginSender = pluginMgr.addSender(send, () => clientId);
|
|
1599
|
+
/** DOM 授权落盘 + notice 反馈(两步握手的 grant 与单步 revoke 共用收尾)。
|
|
1600
|
+
* cs 缺席(未 attach 完成)时只落盘不提示——消息本来就会在 attach 前排队。 */
|
|
1601
|
+
const applyDomConsent = (pluginId, granted) => {
|
|
1602
|
+
void pluginMgr
|
|
1603
|
+
.setDomConsent(pluginId, granted)
|
|
1604
|
+
.then((r) => {
|
|
1605
|
+
const cs2 = clientId ? service.get(clientId) : undefined;
|
|
1606
|
+
if (r.error)
|
|
1607
|
+
cs2?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
|
|
1608
|
+
else if (r.changed)
|
|
1609
|
+
cs2?.emitNotice("info", granted ? `已授权插件「${pluginId}」完全 DOM 访问` : `已撤销插件「${pluginId}」完全 DOM 访问`, granted
|
|
1610
|
+
? `Granted full DOM access to plugin "${pluginId}"`
|
|
1611
|
+
: `Revoked full DOM access from plugin "${pluginId}"`);
|
|
1612
|
+
})
|
|
1613
|
+
.catch(() => { });
|
|
1614
|
+
};
|
|
1521
1615
|
const dispatch = (msg) => {
|
|
1522
1616
|
if (!clientId) {
|
|
1523
1617
|
pending.push(msg);
|
|
1524
1618
|
return;
|
|
1525
1619
|
}
|
|
1526
1620
|
const cs = service.get(clientId);
|
|
1527
|
-
if (!cs) {
|
|
1528
|
-
// Session not ready yet (hello processing) — hold the command.
|
|
1621
|
+
if (!cs || !attachDone) {
|
|
1622
|
+
// Session not ready yet (hello processing / plugin chain) — hold the command.
|
|
1529
1623
|
pending.push(msg);
|
|
1530
1624
|
return;
|
|
1531
1625
|
}
|
|
@@ -1771,6 +1865,11 @@ wss.on("connection", (ws) => {
|
|
|
1771
1865
|
case "check_updates_all":
|
|
1772
1866
|
void cs.checkUpdatesAll(msg.force === true);
|
|
1773
1867
|
break;
|
|
1868
|
+
case "check_plugin_updates":
|
|
1869
|
+
if (typeof cs.checkPluginUpdates === "function") {
|
|
1870
|
+
void cs.checkPluginUpdates(true);
|
|
1871
|
+
}
|
|
1872
|
+
break;
|
|
1774
1873
|
case "restart_service": {
|
|
1775
1874
|
// Same effect as `pi-web-ui server restart`: this process exits and its
|
|
1776
1875
|
// supervisor brings it back (launchd/systemd immediately, the Windows
|
|
@@ -1847,10 +1946,10 @@ wss.on("connection", (ws) => {
|
|
|
1847
1946
|
void cs.listProviders();
|
|
1848
1947
|
break;
|
|
1849
1948
|
case "fetch_models":
|
|
1850
|
-
void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
|
|
1949
|
+
void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
|
|
1851
1950
|
break;
|
|
1852
1951
|
case "test_model_connection":
|
|
1853
|
-
void cs.testModelConnection?.(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
|
|
1952
|
+
void cs.testModelConnection?.(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
|
|
1854
1953
|
break;
|
|
1855
1954
|
case "refresh_provider_models":
|
|
1856
1955
|
void cs.refreshProviderModels(msg.providerId, msg.reqId);
|
|
@@ -2035,37 +2134,100 @@ wss.on("connection", (ws) => {
|
|
|
2035
2134
|
const jobLang = () => cs?.getLang() ?? "en";
|
|
2036
2135
|
const jobId = String(msg.jobId ?? "");
|
|
2037
2136
|
const pluginId = String(msg.id ?? "");
|
|
2038
|
-
|
|
2137
|
+
// function 声明会提升、TS 对 msg 判别联合的收窄进不了闭包 —— 先拍平成常量。
|
|
2138
|
+
const jobAction = msg.action;
|
|
2139
|
+
const jobSpec = {
|
|
2039
2140
|
jobId,
|
|
2040
|
-
action:
|
|
2141
|
+
action: jobAction,
|
|
2041
2142
|
id: pluginId,
|
|
2042
2143
|
source: msg.source,
|
|
2043
2144
|
build: msg.build === true,
|
|
2044
2145
|
noBuild: msg.noBuild === true,
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
emit: (m) => send(m),
|
|
2048
|
-
done: async (ok, info) => {
|
|
2049
|
-
if (ok) {
|
|
2050
|
-
await reloadPluginsAndPush(jobLang);
|
|
2051
|
-
}
|
|
2052
|
-
else if (info.error) {
|
|
2053
|
-
cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
|
|
2054
|
-
}
|
|
2055
|
-
},
|
|
2056
|
-
});
|
|
2057
|
-
if (!started.ok) {
|
|
2058
|
-
// 被拒(忙 / 托管实例 / 参数非法)也要回一条 done,让面板上的作业就地结束。
|
|
2146
|
+
};
|
|
2147
|
+
const jobDone = (ok, error) => {
|
|
2059
2148
|
send({
|
|
2060
2149
|
type: "plugin_job",
|
|
2061
2150
|
jobId,
|
|
2062
|
-
action:
|
|
2151
|
+
action: jobAction,
|
|
2063
2152
|
pluginId,
|
|
2064
2153
|
phase: "done",
|
|
2065
|
-
ok
|
|
2066
|
-
error:
|
|
2154
|
+
ok,
|
|
2155
|
+
...(error ? { error } : {}),
|
|
2067
2156
|
output: "",
|
|
2068
2157
|
});
|
|
2158
|
+
};
|
|
2159
|
+
// 参数静态校验:参数非法直接拒绝,避免向客户端发起无意义/恶意的确认弹窗。
|
|
2160
|
+
const argCheck = buildPluginJobArgs(jobSpec, DATA_DIR, jobLang);
|
|
2161
|
+
if ("error" in argCheck) {
|
|
2162
|
+
jobDone(false, argCheck.error);
|
|
2163
|
+
break;
|
|
2164
|
+
}
|
|
2165
|
+
// 安装确认门(P0):install/update 先经用户确认。第三方页面脚本可以直发
|
|
2166
|
+
// plugin_job;拒绝/超时直接回一条 done,让面板上的作业就地结束(不占
|
|
2167
|
+
// 安装锁、不弹「失败」之外的噪音)。卸载不在本门范围内(由面板本身发起)。
|
|
2168
|
+
if (jobAction === "install" || jobAction === "update") {
|
|
2169
|
+
const alreadyGranted = pluginMgr.permGrants.has("plugin-installer", "net", { host: "github.com" });
|
|
2170
|
+
if (!alreadyGranted) {
|
|
2171
|
+
send({
|
|
2172
|
+
type: "plugin_job",
|
|
2173
|
+
jobId,
|
|
2174
|
+
action: jobAction,
|
|
2175
|
+
pluginId,
|
|
2176
|
+
phase: "start",
|
|
2177
|
+
});
|
|
2178
|
+
send({
|
|
2179
|
+
type: "plugin_job",
|
|
2180
|
+
jobId,
|
|
2181
|
+
action: jobAction,
|
|
2182
|
+
pluginId,
|
|
2183
|
+
phase: "log",
|
|
2184
|
+
line: jobLang() === "zh" ? "等待确认安装授权…" : "Waiting for install confirmation…",
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
void confirmPluginInstallHelper([{ id: pluginId, source: String(msg.source ?? "") }])
|
|
2188
|
+
.then((confirmed) => {
|
|
2189
|
+
if (!confirmed) {
|
|
2190
|
+
jobDone(false, jobLang() === "zh"
|
|
2191
|
+
? "用户未确认安装(拒绝或 120 秒超时)"
|
|
2192
|
+
: "Installation not confirmed (rejected or timed out after 120s)");
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
startPluginJob();
|
|
2196
|
+
})
|
|
2197
|
+
.catch(() => jobDone(false, jobLang() === "zh" ? "安装确认流程异常" : "Installation confirmation error"));
|
|
2198
|
+
break;
|
|
2199
|
+
}
|
|
2200
|
+
startPluginJob();
|
|
2201
|
+
// 真正派发作业(确认门通过后走这里):被拒(忙 / 托管实例 / 参数非法)也要回
|
|
2202
|
+
// 一条 done,让面板上的作业就地结束。
|
|
2203
|
+
function startPluginJob() {
|
|
2204
|
+
const started = pluginInstaller.start(jobSpec, {
|
|
2205
|
+
lang: jobLang,
|
|
2206
|
+
emit: (m) => send(m),
|
|
2207
|
+
done: async (ok, info) => {
|
|
2208
|
+
if (ok) {
|
|
2209
|
+
await reloadPluginsAndPush(jobLang);
|
|
2210
|
+
const isZh = jobLang() === "zh";
|
|
2211
|
+
const actionLabel = jobAction === "uninstall"
|
|
2212
|
+
? isZh
|
|
2213
|
+
? "卸载"
|
|
2214
|
+
: "uninstalled"
|
|
2215
|
+
: jobAction === "update"
|
|
2216
|
+
? isZh
|
|
2217
|
+
? "更新"
|
|
2218
|
+
: "updated"
|
|
2219
|
+
: isZh
|
|
2220
|
+
? "安装"
|
|
2221
|
+
: "installed";
|
|
2222
|
+
cs?.emitNotice("info", `插件「${pluginId}」${actionLabel}完成`, `Plugin "${pluginId}" ${actionLabel} successfully`);
|
|
2223
|
+
}
|
|
2224
|
+
else if (info.error) {
|
|
2225
|
+
cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
});
|
|
2229
|
+
if (!started.ok)
|
|
2230
|
+
jobDone(false, started.error);
|
|
2069
2231
|
}
|
|
2070
2232
|
break;
|
|
2071
2233
|
}
|
|
@@ -2104,10 +2266,13 @@ wss.on("connection", (ws) => {
|
|
|
2104
2266
|
}
|
|
2105
2267
|
// -- 插件目录授权(issue #146)------------------------------------------
|
|
2106
2268
|
case "plugin_path_response": {
|
|
2107
|
-
const
|
|
2108
|
-
|
|
2269
|
+
const id = String(msg.id ?? "");
|
|
2270
|
+
const pending = pendingPathRequests.get(id);
|
|
2271
|
+
// 来源绑定:应答必须来自弹窗广播时在线的 clientId——广播后才连上的
|
|
2272
|
+
// 端没见过弹窗,忽略其代答(pending 保留,真正的弹窗端仍可答复)。
|
|
2273
|
+
if (pending && clientId && pending.recipients.has(clientId)) {
|
|
2109
2274
|
clearTimeout(pending.timer);
|
|
2110
|
-
pendingPathRequests.delete(
|
|
2275
|
+
pendingPathRequests.delete(id);
|
|
2111
2276
|
pending.resolve(msg.ok === true);
|
|
2112
2277
|
}
|
|
2113
2278
|
break;
|
|
@@ -2116,7 +2281,8 @@ wss.on("connection", (ws) => {
|
|
|
2116
2281
|
case "plugin_permission_response": {
|
|
2117
2282
|
const id = String(msg.id ?? "");
|
|
2118
2283
|
const pending = pendingPermissionRequests.get(id);
|
|
2119
|
-
|
|
2284
|
+
// 来源绑定:同 plugin_path_response——只有收到弹窗广播的端可代答。
|
|
2285
|
+
if (pending && clientId && pending.recipients.has(clientId)) {
|
|
2120
2286
|
clearTimeout(pending.timer);
|
|
2121
2287
|
pendingPermissionRequests.delete(id);
|
|
2122
2288
|
// 先答复者胜:通知其它在线端收起同一条请求(与目录授权不同,这里要显式 resolved)。
|
|
@@ -2136,19 +2302,49 @@ wss.on("connection", (ws) => {
|
|
|
2136
2302
|
break;
|
|
2137
2303
|
}
|
|
2138
2304
|
case "plugin_dom_consent": {
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2305
|
+
// grant 是提权方向:走两步握手——只生成在途 consent 请求并广播,不在此
|
|
2306
|
+
// 处落盘;收到绑定来源的 plugin_dom_consent_response 后才 setDomConsent。
|
|
2307
|
+
// 同 pluginId 已有在途请求时忽略(首个优先,120s 超时自动失效)。
|
|
2308
|
+
if (msg.granted === true) {
|
|
2309
|
+
const pid = typeof msg.pluginId === "string" ? msg.pluginId.trim() : "";
|
|
2310
|
+
if (!pid || !pluginMgr.isDomPlugin(pid) || pendingDomConsents.has(pid))
|
|
2311
|
+
break;
|
|
2312
|
+
const id = randomUUID();
|
|
2313
|
+
const timer = setTimeout(() => {
|
|
2314
|
+
pendingDomConsents.delete(pid);
|
|
2315
|
+
}, 120_000);
|
|
2316
|
+
pendingDomConsents.set(pid, { id, pluginId: pid, recipients: new Set(pluginMgr.onlineClientIds()), timer });
|
|
2317
|
+
// from = 发起端 clientId:前端只自动应答自己发起的授权(设置面板点击)。
|
|
2318
|
+
const payload = JSON.stringify({ type: "plugin_dom_consent_request", id, pluginId: pid, from: clientId });
|
|
2319
|
+
for (const client of wss.clients) {
|
|
2320
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2321
|
+
try {
|
|
2322
|
+
client.send(payload);
|
|
2323
|
+
}
|
|
2324
|
+
catch {
|
|
2325
|
+
/* 死连接 */
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
break;
|
|
2330
|
+
}
|
|
2331
|
+
// revoke 是降权方向:单步直达(不在途等待),pluginId 校验交给 setDomConsent。
|
|
2332
|
+
applyDomConsent(msg.pluginId, false);
|
|
2333
|
+
break;
|
|
2334
|
+
}
|
|
2335
|
+
// -- DOM 授权两步握手的应答:按 id 回查在途请求,且应答连接必须在弹窗
|
|
2336
|
+
// 广播时的在线端集合里,才允许把 grant 持久写盘。
|
|
2337
|
+
case "plugin_dom_consent_response": {
|
|
2338
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2339
|
+
const pendingConsent = findDomConsentById(id);
|
|
2340
|
+
if (pendingConsent && clientId && pendingConsent.recipients.has(clientId)) {
|
|
2341
|
+
clearTimeout(pendingConsent.timer);
|
|
2342
|
+
pendingDomConsents.delete(pendingConsent.pluginId);
|
|
2343
|
+
// ok=false = 显式拒绝:只清在途请求不落盘(当前前端自动应答只发
|
|
2344
|
+
// true,这里守住协议语义,将来接拒绝按钮不用动服务端)。
|
|
2345
|
+
if (msg.ok === true)
|
|
2346
|
+
applyDomConsent(pendingConsent.pluginId, true);
|
|
2347
|
+
}
|
|
2152
2348
|
break;
|
|
2153
2349
|
}
|
|
2154
2350
|
case "plugin_path_revoke": {
|
|
@@ -2180,7 +2376,14 @@ wss.on("connection", (ws) => {
|
|
|
2180
2376
|
// 只更新市场列表时无需重启已激活插件,避免重复广播工作目录。
|
|
2181
2377
|
afterWrite: () => (msg.install === true ? reloadPluginsAndPush(syncLang) : pluginMgr.pushCatalog()),
|
|
2182
2378
|
lang: syncLang,
|
|
2379
|
+
// 本地文件来源只允许工作区内(防任意路径文件探测 oracle)。
|
|
2380
|
+
workspaceRoot: cs?.cwd ?? CWD,
|
|
2381
|
+
// 安装确认门(P0):拒绝/超时只写目录不安装。
|
|
2382
|
+
confirmInstall: (items) => confirmPluginInstallHelper(items),
|
|
2183
2383
|
}).then((r) => {
|
|
2384
|
+
if (r.installRefused) {
|
|
2385
|
+
cs?.emitNotice("warning", "目录已同步,但安装未获用户确认(拒绝或超时),未安装任何插件", "Catalog synced, but installation was not confirmed (denied or timed out) — nothing was installed");
|
|
2386
|
+
}
|
|
2184
2387
|
send({
|
|
2185
2388
|
type: "plugin_catalog_sync_result",
|
|
2186
2389
|
requestId,
|
|
@@ -2346,6 +2549,22 @@ wss.on("connection", (ws) => {
|
|
|
2346
2549
|
}
|
|
2347
2550
|
}
|
|
2348
2551
|
};
|
|
2552
|
+
/** ready 握手帧:首连与重复 hello 的幂等回包共用同一构造,避免两处漂移。 */
|
|
2553
|
+
const readyMsg = (cid) => ({
|
|
2554
|
+
type: "ready",
|
|
2555
|
+
clientId: cid,
|
|
2556
|
+
serverVersion: VERSION,
|
|
2557
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2558
|
+
engine: ENGINE,
|
|
2559
|
+
// This package's own version. `serverVersion` is the pi SDK's,
|
|
2560
|
+
// and the client used to learn ours from the update check —
|
|
2561
|
+
// which a managed instance never runs.
|
|
2562
|
+
appVersion: appVersion(),
|
|
2563
|
+
buildId: buildId(),
|
|
2564
|
+
managed: MANAGED,
|
|
2565
|
+
tabs: TABS ? [...TABS] : undefined,
|
|
2566
|
+
service: SERVICE_INFO ?? undefined,
|
|
2567
|
+
});
|
|
2349
2568
|
ws.on("message", (data) => {
|
|
2350
2569
|
let msg;
|
|
2351
2570
|
try {
|
|
@@ -2355,27 +2574,25 @@ wss.on("connection", (ws) => {
|
|
|
2355
2574
|
return;
|
|
2356
2575
|
}
|
|
2357
2576
|
if (msg.type === "hello") {
|
|
2358
|
-
|
|
2577
|
+
// 重放守卫:一条连接只允许 attach 一次。clientId 已赋值说明 hello 处理过
|
|
2578
|
+
//(或进行中),再来的 hello 幂等回一条 ready 即可——否则重复 hello 会把
|
|
2579
|
+
// service.attach 整个再跑一遍:重型 ClientSession 重复创建 + 旧 send sink
|
|
2580
|
+
// 永久泄漏(removePluginSender 只在 close 时清一次)。
|
|
2581
|
+
if (clientId) {
|
|
2582
|
+
if (!closed)
|
|
2583
|
+
send(readyMsg(clientId));
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
// clientId 校验:外部输入,类型/长度/字符集不合格直接换 randomUUID()——
|
|
2587
|
+
// 它随后成为 ClientSession key 与上传落盘目录名(uploads/<clientId>/,
|
|
2588
|
+
// saveUpload 的两条路径都从这里来,入口统一拦一次即可)。
|
|
2589
|
+
const cid = validateClientId(msg.clientId) ?? randomUUID();
|
|
2359
2590
|
clientId = cid;
|
|
2360
2591
|
// issue #295:ready 先行 —— 传输握手不等待会话初始化。attach 会进 SDK 的
|
|
2361
2592
|
// resourceLoader.reload 等同步目录扫描,坏挂载/家目录下可能阻塞数十秒;
|
|
2362
2593
|
// ready 在握手里先发,前端立刻离开「正在连接」(快照随后到)。
|
|
2363
2594
|
if (!closed) {
|
|
2364
|
-
send(
|
|
2365
|
-
type: "ready",
|
|
2366
|
-
clientId: cid,
|
|
2367
|
-
serverVersion: VERSION,
|
|
2368
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
2369
|
-
engine: ENGINE,
|
|
2370
|
-
// This package's own version. `serverVersion` is the pi SDK's,
|
|
2371
|
-
// and the client used to learn ours from the update check —
|
|
2372
|
-
// which a managed instance never runs.
|
|
2373
|
-
appVersion: appVersion(),
|
|
2374
|
-
buildId: buildId(),
|
|
2375
|
-
managed: MANAGED,
|
|
2376
|
-
tabs: TABS ? [...TABS] : undefined,
|
|
2377
|
-
service: SERVICE_INFO ?? undefined,
|
|
2378
|
-
});
|
|
2595
|
+
send(readyMsg(cid));
|
|
2379
2596
|
}
|
|
2380
2597
|
// attach 慢提示:本体继续等,不取消;完成后照常走快照流程。
|
|
2381
2598
|
const slowTimer = setTimeout(() => {
|
|
@@ -2429,6 +2646,7 @@ wss.on("connection", (ws) => {
|
|
|
2429
2646
|
catch {
|
|
2430
2647
|
/* 推送失败不挡快照 */
|
|
2431
2648
|
}
|
|
2649
|
+
replayQueued();
|
|
2432
2650
|
})
|
|
2433
2651
|
.catch(() => {
|
|
2434
2652
|
if (closed)
|
|
@@ -2436,16 +2654,23 @@ wss.on("connection", (ws) => {
|
|
|
2436
2654
|
// ensureLoaded 失败(如磁盘读错)不能卡死快照——前端 30s 无消息
|
|
2437
2655
|
// 会重连,重连又失败会陷入循环。至少把状态推下去。
|
|
2438
2656
|
cs.flushSnapshot();
|
|
2657
|
+
replayQueued();
|
|
2439
2658
|
});
|
|
2440
2659
|
// hello may carry the UI locale — persist it before replaying
|
|
2441
2660
|
// anything queued during startup (issue #91).
|
|
2442
2661
|
if (msg.locale)
|
|
2443
2662
|
void service.setLocale(cid, msg.locale);
|
|
2444
|
-
//
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2663
|
+
// attach 期间收到的命令先排队(dispatch 里的 pending),必须等插件链
|
|
2664
|
+
// 就绪后再重放:prompt 里可能是插件命令(/probe-grant 等),目录由
|
|
2665
|
+
// 下面的 applyPluginCommandCatalog 同步;提前重放会撞上「未知命令」。
|
|
2666
|
+
// ready 先行(issue #295)之前,客户端收到 ready 时 attach 已完成,
|
|
2667
|
+
// 首条命令天然落在插件加载之后;现在 ready 与 attach 脱钩,不等就重放
|
|
2668
|
+
// 等于把竞态窗口从一个 RTT 放大到整个插件扫描期(CI 必现 grant 超时)。
|
|
2669
|
+
const replayQueued = () => {
|
|
2670
|
+
attachDone = true;
|
|
2671
|
+
for (const m of pending.drain())
|
|
2672
|
+
dispatch(m);
|
|
2673
|
+
};
|
|
2449
2674
|
})
|
|
2450
2675
|
.catch((err) => {
|
|
2451
2676
|
// Admission refused (quiesce): close the socket so the browser
|
|
@@ -2455,13 +2680,40 @@ wss.on("connection", (ws) => {
|
|
|
2455
2680
|
if (err instanceof QuiesceRejectedError) {
|
|
2456
2681
|
closed = true;
|
|
2457
2682
|
if (ws.readyState === WebSocket.OPEN) {
|
|
2458
|
-
|
|
2683
|
+
// 先发 4403 关闭帧,等它刷出去再真正撕连接:close 后立刻
|
|
2684
|
+
// terminate 会把关闭握手一起掐掉,客户端只能看到 1006
|
|
2685
|
+
// (quiesce-test 的 brand-new client 断言 4403)。
|
|
2686
|
+
try {
|
|
2687
|
+
ws.close(4403, "quiesced");
|
|
2688
|
+
}
|
|
2689
|
+
catch {
|
|
2690
|
+
/* already closing */
|
|
2691
|
+
}
|
|
2692
|
+
setTimeout(() => {
|
|
2693
|
+
try {
|
|
2694
|
+
if (ws.readyState !== WebSocket.CLOSED)
|
|
2695
|
+
ws.terminate?.();
|
|
2696
|
+
}
|
|
2697
|
+
catch {
|
|
2698
|
+
/* ignore */
|
|
2699
|
+
}
|
|
2700
|
+
}, 500);
|
|
2701
|
+
}
|
|
2702
|
+
else {
|
|
2703
|
+
try {
|
|
2704
|
+
ws.terminate?.();
|
|
2705
|
+
}
|
|
2706
|
+
catch {
|
|
2707
|
+
/* ignore */
|
|
2708
|
+
}
|
|
2459
2709
|
}
|
|
2460
|
-
ws.terminate?.();
|
|
2461
2710
|
return;
|
|
2462
2711
|
}
|
|
2463
2712
|
// Real init failure (bad agent dir etc.) — keep the connection
|
|
2464
2713
|
// open so the user can see the error and fix it.
|
|
2714
|
+
// 排队的命令此时无会话可服务,直接丢弃(否则 attachDone 永 false,
|
|
2715
|
+
// 队列越积越深;用户修好后重连会重发 get_state)。
|
|
2716
|
+
pending.clear();
|
|
2465
2717
|
send({
|
|
2466
2718
|
type: "notice",
|
|
2467
2719
|
level: "error",
|
|
@@ -2476,7 +2728,7 @@ wss.on("connection", (ws) => {
|
|
|
2476
2728
|
ws.on("close", () => {
|
|
2477
2729
|
service.noteSocketClose();
|
|
2478
2730
|
closed = true;
|
|
2479
|
-
pending
|
|
2731
|
+
pending.clear();
|
|
2480
2732
|
removePluginSender();
|
|
2481
2733
|
if (snapshotRetryTimer) {
|
|
2482
2734
|
clearTimeout(snapshotRetryTimer);
|
|
@@ -2517,11 +2769,12 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
2517
2769
|
console.log(` session dir : ${SESSION_DIR_ROOT}`);
|
|
2518
2770
|
console.log(` pi SDK : v${VERSION}`);
|
|
2519
2771
|
// issue #260:全局那份 pi SDK 不是服务在用的那份(自带副本赢在 Node 解析顺序上)。
|
|
2520
|
-
//
|
|
2772
|
+
// #321 起默认已反转(resolve-global-sdk 自动跟随更新的那份),这条提示只在
|
|
2773
|
+
// 「进程没跟上」(升级发生在启动后 / 旧构建没注入钩子)或显式 PI_WEB_SDK=bundled
|
|
2774
|
+
// 钉死自带副本时出现。
|
|
2521
2775
|
const sdkNote = sdkOriginNote(sdkCopies(), VERSION);
|
|
2522
2776
|
if (sdkNote) {
|
|
2523
2777
|
console.log(` pi SDK note : ${sdkNote}`);
|
|
2524
|
-
console.log(` Upgrading the global pi CLI does not change this server — upgrade pi-web-ui instead.`);
|
|
2525
2778
|
}
|
|
2526
2779
|
console.log(` bind : ${HOST}:${PORT}`);
|
|
2527
2780
|
console.log("");
|