pi-web-ui 0.86.2 → 0.87.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 +84 -23
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +377 -25
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +535 -54
- package/dist/server/dsh/dsh-client.js +28 -0
- package/dist/server/dsh/dsh-sessions.js +28 -0
- package/dist/server/dsh/dsh-usage.js +82 -0
- package/dist/server/dsh/preset-clones.js +260 -0
- package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
- package/dist/server/dsh/runtime/launcher.mjs +17 -0
- package/dist/server/dsh/runtime/override.patch.yml +19 -1
- package/dist/server/files-service.js +259 -1
- package/dist/server/index.js +244 -5
- package/dist/server/mcp-bridge.js +126 -22
- package/dist/server/mcp-hot-reload.js +117 -0
- package/dist/server/model-admin.js +93 -3
- package/dist/server/plugin-dom.js +83 -0
- package/dist/server/plugin-facilities.js +15 -1
- package/dist/server/plugin-installer.js +6 -0
- package/dist/server/plugins.js +781 -74
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/provider-oauth-flow.js +157 -0
- package/dist/server/update-check.js +22 -0
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +65 -22
- package/web/dist/assets/index-8xCnPMZP.js +374 -0
- package/web/dist/assets/index-F86qWlJy.css +41 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
- package/web/dist/assets/index-CKoVyDkP.css +0 -10
- package/web/dist/assets/index-Dmwji4Cr.js +0 -361
package/dist/server/plugins.js
CHANGED
|
@@ -17,17 +17,20 @@
|
|
|
17
17
|
* - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
|
|
18
18
|
*/
|
|
19
19
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
20
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, renameSync, watch as fsWatch, writeFileSync } from "node:fs";
|
|
21
21
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
22
|
-
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
23
23
|
import { pick } from "./i18n.js";
|
|
24
24
|
import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
|
|
25
25
|
import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
|
|
26
26
|
import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
|
|
27
|
+
import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
|
|
27
28
|
// 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
|
|
28
29
|
import { normalizeWorkspaceRoots } from "./client-state.js";
|
|
29
30
|
import { createProject } from "./plugin-project.js";
|
|
30
31
|
import { createHash } from "node:crypto";
|
|
32
|
+
import { execFile } from "node:child_process";
|
|
33
|
+
import { promisify } from "node:util";
|
|
31
34
|
/** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
|
|
32
35
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
33
36
|
/** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
|
|
@@ -42,6 +45,73 @@ const MESSAGE_TIMEOUT_MS = 30_000;
|
|
|
42
45
|
/** host.fs 被能力门控拒绝时的共享 rejected promise(类型对齐用)。 */
|
|
43
46
|
const NO_FS_PROMISE = Promise.reject(new Error('插件未声明能力 "fs"(manifest.permissions)——请求被拒'));
|
|
44
47
|
NO_FS_PROMISE.catch(() => { }); // 避免未处理 rejection 噪音;调用方 await 时拿到错误
|
|
48
|
+
/** 只读插件(仅声明 fs:read)撞到写操作时的共享 rejected promise:提示缺 fs/fs:write。 */
|
|
49
|
+
const NO_FS_WRITE_PROMISE = Promise.reject(new Error('缺少写能力:需要 "fs"/"fs:write"(manifest.permissions)——请求被拒'));
|
|
50
|
+
NO_FS_WRITE_PROMISE.catch(() => { }); // 同上:调用方 await 时拿到错误
|
|
51
|
+
/** host.bash 的执行体:execFile 直跑(不过 shell),与宿主其它 git/scm 查询同口径。 */
|
|
52
|
+
const execFileAsync = promisify(execFile);
|
|
53
|
+
/** 简单版本号三元组(x.y.z,忽略 -prerelease/+build 后缀)。 */
|
|
54
|
+
function parseVer(v) {
|
|
55
|
+
const m = String(v ?? "")
|
|
56
|
+
.trim()
|
|
57
|
+
.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
58
|
+
if (!m)
|
|
59
|
+
return null;
|
|
60
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
61
|
+
}
|
|
62
|
+
function cmpVerTuple(a, b) {
|
|
63
|
+
for (let i = 0; i < 3; i++) {
|
|
64
|
+
if (a[i] !== b[i])
|
|
65
|
+
return a[i] - b[i];
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
/** manifest engines["pi-web-ui"] 约束判定(支持 >=x.y.z / ^x.y.z / =x.y.z/裸 x.y.z)。
|
|
70
|
+
* 返回 null = 约束或当前版本解析失败(调用方警告放行,不阻断激活)。 */
|
|
71
|
+
export function satisfiesEnginesConstraint(constraint, current) {
|
|
72
|
+
const c = String(constraint ?? "").trim();
|
|
73
|
+
let kind, req;
|
|
74
|
+
if (c.startsWith(">=")) {
|
|
75
|
+
kind = ">=";
|
|
76
|
+
req = c.slice(2).trim();
|
|
77
|
+
}
|
|
78
|
+
else if (c.startsWith("^")) {
|
|
79
|
+
kind = "^";
|
|
80
|
+
req = c.slice(1).trim();
|
|
81
|
+
}
|
|
82
|
+
else if (c.startsWith("=")) {
|
|
83
|
+
kind = "=";
|
|
84
|
+
req = c.slice(1).trim();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
kind = "=";
|
|
88
|
+
req = c;
|
|
89
|
+
}
|
|
90
|
+
const r = parseVer(req);
|
|
91
|
+
const cur = parseVer(current);
|
|
92
|
+
if (!r || !cur)
|
|
93
|
+
return null;
|
|
94
|
+
if (kind === ">=")
|
|
95
|
+
return cmpVerTuple(cur, r) >= 0;
|
|
96
|
+
if (kind === "=")
|
|
97
|
+
return cmpVerTuple(cur, r) === 0;
|
|
98
|
+
return cur[0] === r[0] && cmpVerTuple(cur, r) >= 0; // ^:同主版本且不低于
|
|
99
|
+
}
|
|
100
|
+
/** 宿主自身版本(package.json version,读一次缓存;读不到回 null——调用方放行)。 */
|
|
101
|
+
let cachedHostVersion;
|
|
102
|
+
export function readHostVersion() {
|
|
103
|
+
if (cachedHostVersion !== undefined)
|
|
104
|
+
return cachedHostVersion;
|
|
105
|
+
try {
|
|
106
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
107
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
108
|
+
cachedHostVersion = typeof pkg.version === "string" ? pkg.version : null;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
cachedHostVersion = null;
|
|
112
|
+
}
|
|
113
|
+
return cachedHostVersion;
|
|
114
|
+
}
|
|
45
115
|
// ---------------------------------------------------------------------------
|
|
46
116
|
// 声明式设置 schema(manifest "settings")
|
|
47
117
|
// ---------------------------------------------------------------------------
|
|
@@ -65,6 +135,14 @@ const UI_SLOTS = new Set([
|
|
|
65
135
|
"contextmenu.session",
|
|
66
136
|
"contextmenu.file",
|
|
67
137
|
"settings.pages",
|
|
138
|
+
"leftpanel.sessions",
|
|
139
|
+
"chat.header",
|
|
140
|
+
"chat.empty",
|
|
141
|
+
"file.preview.toolbar",
|
|
142
|
+
"terminal.toolbar",
|
|
143
|
+
"scm.toolbar",
|
|
144
|
+
"goalbar.actions",
|
|
145
|
+
"notice.actions",
|
|
68
146
|
]);
|
|
69
147
|
/** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
|
|
70
148
|
const UI_SLOT_ALIASES = {
|
|
@@ -76,7 +154,24 @@ const UI_SLOT_ALIASES = {
|
|
|
76
154
|
settings: "settings.pages",
|
|
77
155
|
};
|
|
78
156
|
/** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
|
|
79
|
-
const UI_KINDS = new Set([
|
|
157
|
+
const UI_KINDS = new Set([
|
|
158
|
+
"view",
|
|
159
|
+
"action",
|
|
160
|
+
"badge",
|
|
161
|
+
"menu",
|
|
162
|
+
"page",
|
|
163
|
+
"organizer",
|
|
164
|
+
"divider",
|
|
165
|
+
"toggle",
|
|
166
|
+
"input",
|
|
167
|
+
"progress",
|
|
168
|
+
]);
|
|
169
|
+
/** 合法的对齐取值(UiAlign):起首/居中/行尾,非法值丢弃(条目保留,align 回缺省)。 */
|
|
170
|
+
const UI_ALIGNS = new Set(["start", "center", "end"]);
|
|
171
|
+
/** 原始值 → 合法 UiAlign(非法/缺失一律 undefined,调用方填缺省)。 */
|
|
172
|
+
export function parseUiAlign(raw) {
|
|
173
|
+
return typeof raw === "string" && UI_ALIGNS.has(raw) ? raw : undefined;
|
|
174
|
+
}
|
|
80
175
|
function trimStr(v, max = 60) {
|
|
81
176
|
return typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
|
|
82
177
|
}
|
|
@@ -109,10 +204,12 @@ export function parseUiItem(raw, slot) {
|
|
|
109
204
|
? o.when.filter((x) => typeof x === "string" && x.trim().length > 0).slice(0, 8)
|
|
110
205
|
: undefined;
|
|
111
206
|
const num = Number(o.order);
|
|
207
|
+
const align = parseUiAlign(o.align);
|
|
112
208
|
return {
|
|
113
209
|
id,
|
|
114
210
|
slot: slot,
|
|
115
211
|
label,
|
|
212
|
+
...(align ? { align } : {}),
|
|
116
213
|
...(trimStr(o.labelEn, 60) ? { labelEn: trimStr(o.labelEn, 60) } : {}),
|
|
117
214
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
118
215
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
@@ -126,6 +223,11 @@ export function parseUiItem(raw, slot) {
|
|
|
126
223
|
...(trimStr(o.view, 64) ? { view: trimStr(o.view, 64) } : {}),
|
|
127
224
|
...(when?.length ? { when } : {}),
|
|
128
225
|
...(trimStr(o.badge, 24) ? { badge: trimStr(o.badge, 24) } : {}),
|
|
226
|
+
...(typeof o.checked === "boolean" ? { checked: o.checked } : {}),
|
|
227
|
+
...(typeof o.value === "string" ? { value: o.value.slice(0, 500) } : {}),
|
|
228
|
+
...(typeof o.progress === "number" && Number.isFinite(o.progress)
|
|
229
|
+
? { progress: Math.max(0, Math.min(100, o.progress)) }
|
|
230
|
+
: {}),
|
|
129
231
|
};
|
|
130
232
|
}
|
|
131
233
|
/**
|
|
@@ -183,6 +285,7 @@ export function parseUiArrange(raw) {
|
|
|
183
285
|
continue;
|
|
184
286
|
const slotRaw = trimStr(o.slot, 32);
|
|
185
287
|
const num = Number(o.order);
|
|
288
|
+
const align = parseUiAlign(o.align);
|
|
186
289
|
out.push({
|
|
187
290
|
id,
|
|
188
291
|
...(slotRaw && UI_SLOTS.has(slotRaw) ? { slot: slotRaw } : {}),
|
|
@@ -190,6 +293,7 @@ export function parseUiArrange(raw) {
|
|
|
190
293
|
...(o.hide === false ? { hide: false } : {}),
|
|
191
294
|
...(trimStr(o.group, 40) ? { group: trimStr(o.group, 40) } : {}),
|
|
192
295
|
...(Number.isFinite(num) ? { order: num } : {}),
|
|
296
|
+
...(align ? { align } : {}),
|
|
193
297
|
...(trimStr(o.label, 60) ? { label: trimStr(o.label, 60) } : {}),
|
|
194
298
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
195
299
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
@@ -345,6 +449,10 @@ export class PluginManager {
|
|
|
345
449
|
workspaceRoots = [];
|
|
346
450
|
/** 插件目录授权表(<dataDir>/plugin-grants.json,issue #146)。 */
|
|
347
451
|
grants;
|
|
452
|
+
/** 特权 DOM 授权表(<dataDir>/plugin-dom.json):wantsDom 插件的 bundle 门禁。 */
|
|
453
|
+
domConsentStore;
|
|
454
|
+
/** scan() 顺手维护的 wantsDom 快照:静态门禁查它,不必每次 readdir。未知 id = 不拦。 */
|
|
455
|
+
domWants = new Map();
|
|
348
456
|
/** 由 index.ts 注入:向浏览器请求「插件要访问这个目录」的用户确认。 */
|
|
349
457
|
pathAccessRequester = undefined;
|
|
350
458
|
/** 由 index.ts 注入:授权表**变了**(新授权落表)时触发 —— 设置面板的「已授权目录」
|
|
@@ -361,6 +469,7 @@ export class PluginManager {
|
|
|
361
469
|
this.builtinCatalogPath = builtinCatalogPath;
|
|
362
470
|
this.cwdValue = resolve(cwd);
|
|
363
471
|
this.grants = new PluginGrantsStore(dataDir);
|
|
472
|
+
this.domConsentStore = new PluginDomConsent(dataDir);
|
|
364
473
|
}
|
|
365
474
|
/** index.ts 在客户端 set_cwd 成功后调用:更新全局工作区并扇出给
|
|
366
475
|
* 所有已激活插件的 onCwdChange 钩子(异常隔离,不炸主进程)。 */
|
|
@@ -645,6 +754,30 @@ export class PluginManager {
|
|
|
645
754
|
const list = await this.scan();
|
|
646
755
|
this.deliverAll({ type: "plugins", plugins: list, epoch: this.epochCounter });
|
|
647
756
|
}
|
|
757
|
+
/** index.ts 静态门禁查这个:wantsDom 且未授权 → 403。 */
|
|
758
|
+
isDomBundleBlocked(pluginId) {
|
|
759
|
+
if (!this.domWants.get(pluginId))
|
|
760
|
+
return false;
|
|
761
|
+
return !this.domConsentStore.has(pluginId);
|
|
762
|
+
}
|
|
763
|
+
/** 特权 DOM 授权/撤销(设置面板 plugin_dom_consent)。
|
|
764
|
+
* 返回 { changed }:变了才 epoch+1 重推(浏览器按新 epoch 重拉 bundle,
|
|
765
|
+
* 失败缓存随 syncPluginViews 的 epoch 切换清掉);目标不是 wantsDom 插件
|
|
766
|
+
* 或 id 非法 → { changed: false, error }。 */
|
|
767
|
+
async setDomConsent(pluginId, granted) {
|
|
768
|
+
const id = String(pluginId ?? "").trim();
|
|
769
|
+
if (!ID_RE.test(id))
|
|
770
|
+
return { changed: false, error: "非法插件 id" };
|
|
771
|
+
if (!this.domWants.get(id))
|
|
772
|
+
return { changed: false, error: "该插件未声明 dom 能力,无需授权" };
|
|
773
|
+
const changed = this.domConsentStore.set(id, granted);
|
|
774
|
+
if (!changed)
|
|
775
|
+
return { changed: false };
|
|
776
|
+
// 授权前后 bundle 的 403/200 状态翻转:epoch+1 让浏览器丢掉旧模块缓存重拉。
|
|
777
|
+
this.epochCounter += 1;
|
|
778
|
+
void this.pushToAll().catch(() => { });
|
|
779
|
+
return { changed: true };
|
|
780
|
+
}
|
|
648
781
|
/** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
|
|
649
782
|
* 返回新目录清单(含激活结果)。重激活后的插件实例是新模块,
|
|
650
783
|
* 内存状态为初始值——逐个客户端触发 onAttach 让它们重推自身状态。 */
|
|
@@ -691,6 +824,111 @@ export class PluginManager {
|
|
|
691
824
|
conversationProvider = undefined;
|
|
692
825
|
/** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
|
|
693
826
|
chatProvider = undefined;
|
|
827
|
+
/** 由 index.ts 接入 agent-service:对话列表(host.conversations.list 的底层)。
|
|
828
|
+
* 同步数组与 Promise 都收(index.ts 接线是 async 的),host 侧归一化。无注入回 []。 */
|
|
829
|
+
conversationLister = undefined;
|
|
830
|
+
/** 由 index.ts 接入 agent-service:对话搜索(host.conversations.search 的底层)。无注入时
|
|
831
|
+
* host 回退用 conversationLister 做标题过滤;两者都无回 []。 */
|
|
832
|
+
conversationSearcher = undefined;
|
|
833
|
+
/** 由 index.ts 接入 agent-service:向指定对话发用户消息(host.prompt 的底层)。
|
|
834
|
+
* 无注入回 {ok:false},绝不抛错。 */
|
|
835
|
+
conversationWriter = undefined;
|
|
836
|
+
/** 由 index.ts 接入 agent-service:插队指定对话的运行(host.steer 的底层)。无注入回 {ok:false}。 */
|
|
837
|
+
runSteerer = undefined;
|
|
838
|
+
/** 由 index.ts 接入 agent-service:中止指定对话的运行(host.abortRun 的底层)。无注入回 {ok:false}。 */
|
|
839
|
+
runAborter = undefined;
|
|
840
|
+
/** 由 index.ts 接入 agent-service:模型列表(host.models.list 的底层)。无注入回 []。 */
|
|
841
|
+
modelLister = undefined;
|
|
842
|
+
/** 会话统计订阅(host.onStats 注册到这里;emitStats 异常隔离扇出)。 */
|
|
843
|
+
statsHandlers = new Set();
|
|
844
|
+
/** 流式增量订阅(host.onStreaming 注册到这里;emitStreaming 只透传,发送方负责节流)。 */
|
|
845
|
+
streamingHandlers = new Set();
|
|
846
|
+
/** 插件间总线订阅:topic → 处理器集合(host.events.on 注册;反激活时清理该插件全部订阅)。 */
|
|
847
|
+
busHandlers = new Map();
|
|
848
|
+
/** host.chatWait 的 run_end 等待者(emitRunEvent 里按 conversationId 唤醒)。 */
|
|
849
|
+
chatWaiters = [];
|
|
850
|
+
/** 激活期插件的能力门控快照(activate 开头写入、落盘 loaded 或失败即删):
|
|
851
|
+
* canUse 在 host 对象可用之前(loaded.set 之前)也要能判定。 */
|
|
852
|
+
activatingGates = new Map();
|
|
853
|
+
/** 会话统计扇出(异常隔离;订阅者崩了只记日志)。发送方(如定期推送快照统计处)负责节流。 */
|
|
854
|
+
emitStats(s) {
|
|
855
|
+
for (const h of [...this.statsHandlers]) {
|
|
856
|
+
try {
|
|
857
|
+
h(s);
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
console.error("[plugins] stats handler failed:", err);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/** 流式增量透传(异常隔离;宿主不做节流,由发送方保证频率)。 */
|
|
865
|
+
emitStreaming(ev) {
|
|
866
|
+
for (const h of [...this.streamingHandlers]) {
|
|
867
|
+
try {
|
|
868
|
+
h(ev);
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
console.error("[plugins] streaming handler failed:", err);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** 能力门控统一入口(原 activate 内联 can() 的抽出):
|
|
876
|
+
* - 声明 "fs" = 读写全开(向后兼容);仅声明 "fs:read" 时 "fs" 全量不算拥有
|
|
877
|
+
* (读放行、写与 project.create 被拒,错误提示缺 fs/fs:write);
|
|
878
|
+
* - 其它族精确匹配;"dom:anchor" 与 "dom" 互不相干(各管各的族);
|
|
879
|
+
* - 未声明 permissions 且 apiVersion<2 = 旧全权模式(警告一次后放行)。 */
|
|
880
|
+
canUse(pluginId, family) {
|
|
881
|
+
const rec = this.activatingGates.get(pluginId) ?? this.loaded.get(pluginId);
|
|
882
|
+
const fam = String(family ?? "");
|
|
883
|
+
const deny = (what) => {
|
|
884
|
+
console.error(`[plugin:${pluginId}] 缺少能力声明 "${what}"(manifest.permissions)——请求被拒`);
|
|
885
|
+
return false;
|
|
886
|
+
};
|
|
887
|
+
if (!rec)
|
|
888
|
+
return deny(fam);
|
|
889
|
+
const full = new Set(rec.permsDeclared ?? []);
|
|
890
|
+
const strict = rec.strictMode ?? (rec.permsDeclared?.length ?? 0) > 0;
|
|
891
|
+
const legacy = (what) => {
|
|
892
|
+
if (!strict) {
|
|
893
|
+
if (!rec.legacyWarned) {
|
|
894
|
+
rec.legacyWarned = true;
|
|
895
|
+
console.warn(`[plugin:${pluginId}] manifest 未声明 permissions(旧格式全权模式)——已放行 "${what}";apiVersion 2 起将默认拒绝,请尽快声明`);
|
|
896
|
+
}
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
return deny(what);
|
|
900
|
+
};
|
|
901
|
+
if (fam === "fs:read" || fam === "fs:write") {
|
|
902
|
+
if (full.has("fs") || full.has(fam))
|
|
903
|
+
return true;
|
|
904
|
+
return legacy(fam);
|
|
905
|
+
}
|
|
906
|
+
if (fam === "fs") {
|
|
907
|
+
if (full.has("fs"))
|
|
908
|
+
return true;
|
|
909
|
+
return legacy(fam);
|
|
910
|
+
}
|
|
911
|
+
if ((rec.permFamilies ?? new Set()).has(fam))
|
|
912
|
+
return true;
|
|
913
|
+
return legacy(fam);
|
|
914
|
+
}
|
|
915
|
+
/** host.chatWait 等待指定对话的 run_end(默认 120s 超时由调用方钳制后传入)。 */
|
|
916
|
+
waitRunEnd(conversationId, timeoutMs) {
|
|
917
|
+
return new Promise((resolve) => {
|
|
918
|
+
const entry = {
|
|
919
|
+
conversationId,
|
|
920
|
+
done: (ended) => {
|
|
921
|
+
clearTimeout(timer);
|
|
922
|
+
const i = this.chatWaiters.indexOf(entry);
|
|
923
|
+
if (i >= 0)
|
|
924
|
+
this.chatWaiters.splice(i, 1);
|
|
925
|
+
resolve(ended);
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
const timer = setTimeout(() => entry.done(false), timeoutMs);
|
|
929
|
+
this.chatWaiters.push(entry);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
694
932
|
/** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
|
|
695
933
|
getActiveConversation() {
|
|
696
934
|
try {
|
|
@@ -718,8 +956,21 @@ export class PluginManager {
|
|
|
718
956
|
}
|
|
719
957
|
}
|
|
720
958
|
/** agent-service 调:把运行轨迹事件扇出给所有插件(异常隔离,
|
|
721
|
-
* 与 emitToolEvent 同级;订阅者崩了只记日志,不影响主流程)。
|
|
959
|
+
* 与 emitToolEvent 同级;订阅者崩了只记日志,不影响主流程)。
|
|
960
|
+
* run_end 顺带唤醒 host.chatWait 的等待者(按 conversationId 匹配)。 */
|
|
722
961
|
emitRunEvent(ev) {
|
|
962
|
+
if (ev.type === "run_end" && ev.conversationId) {
|
|
963
|
+
for (const w of [...this.chatWaiters]) {
|
|
964
|
+
if (w.conversationId !== ev.conversationId)
|
|
965
|
+
continue;
|
|
966
|
+
try {
|
|
967
|
+
w.done(true);
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
console.error(`[plugins] chatWait waiter failed:`, err);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
723
974
|
for (const p of this.loaded.values()) {
|
|
724
975
|
if (p.runHandlers.size === 0)
|
|
725
976
|
continue;
|
|
@@ -884,6 +1135,27 @@ export class PluginManager {
|
|
|
884
1135
|
}
|
|
885
1136
|
return found.map((f) => this.loaded.get(f.id)?.info ?? f);
|
|
886
1137
|
}
|
|
1138
|
+
/** 反激活清理:把该插件名下全部订阅/注册一次收完(工具/命令/watch/定时/
|
|
1139
|
+
* 总线/stats/流式——参考 agentToolUnsubscribers 模式,新增订阅一律走这里)。 */
|
|
1140
|
+
releaseEntry(p) {
|
|
1141
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
1142
|
+
for (const off of [
|
|
1143
|
+
...(p.agentToolUnsubscribers ?? []),
|
|
1144
|
+
...(p.commandUnsubscribers ?? []),
|
|
1145
|
+
...(p.watchUnsubscribers ?? []),
|
|
1146
|
+
...(p.scheduleUnsubscribers ?? []),
|
|
1147
|
+
...(p.busUnsubscribers ?? []),
|
|
1148
|
+
...(p.statsUnsubscribers ?? []),
|
|
1149
|
+
...(p.streamingUnsubscribers ?? []),
|
|
1150
|
+
]) {
|
|
1151
|
+
try {
|
|
1152
|
+
off();
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
/* already gone */
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
887
1159
|
/** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
|
|
888
1160
|
*
|
|
889
1161
|
* 注意这里必须把 id 从 attempted 里摘掉:目录一时不在(`pi-web-ui install --force`
|
|
@@ -898,15 +1170,7 @@ export class PluginManager {
|
|
|
898
1170
|
catch (err) {
|
|
899
1171
|
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
900
1172
|
}
|
|
901
|
-
|
|
902
|
-
for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
|
|
903
|
-
try {
|
|
904
|
-
off();
|
|
905
|
-
}
|
|
906
|
-
catch {
|
|
907
|
-
/* already gone */
|
|
908
|
-
}
|
|
909
|
-
}
|
|
1173
|
+
this.releaseEntry(p);
|
|
910
1174
|
this.loaded.delete(id);
|
|
911
1175
|
this.messageHandlers.delete(id);
|
|
912
1176
|
this.attempted.delete(id);
|
|
@@ -928,14 +1192,7 @@ export class PluginManager {
|
|
|
928
1192
|
catch (err) {
|
|
929
1193
|
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
930
1194
|
}
|
|
931
|
-
|
|
932
|
-
try {
|
|
933
|
-
off();
|
|
934
|
-
}
|
|
935
|
-
catch {
|
|
936
|
-
/* shutting down */
|
|
937
|
-
}
|
|
938
|
-
}
|
|
1195
|
+
this.releaseEntry(p);
|
|
939
1196
|
// 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
|
|
940
1197
|
for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
|
|
941
1198
|
try {
|
|
@@ -966,7 +1223,10 @@ export class PluginManager {
|
|
|
966
1223
|
if (!(await stat(dir)).isDirectory())
|
|
967
1224
|
continue;
|
|
968
1225
|
const raw = await readFile(join(dir, "manifest.json"), "utf8");
|
|
1226
|
+
// wantsDom 快照供静态门禁用(manifest 坏了 → JSON.parse 抛 → catch 跳过,旧快照残留无害:门禁只拦「快照里要且没授权」的)。
|
|
969
1227
|
const m = JSON.parse(raw);
|
|
1228
|
+
const wantsDom = declarationWantsDom(m.permissions);
|
|
1229
|
+
this.domWants.set(name, wantsDom);
|
|
970
1230
|
out.push({
|
|
971
1231
|
id: name,
|
|
972
1232
|
name: typeof m.name === "string" && m.name ? m.name : name,
|
|
@@ -979,6 +1239,37 @@ export class PluginManager {
|
|
|
979
1239
|
permissions: Array.isArray(m.permissions)
|
|
980
1240
|
? m.permissions.filter((p) => typeof p === "string" && p.length > 0).slice(0, 16)
|
|
981
1241
|
: undefined,
|
|
1242
|
+
// 出站网络白名单(permissions 含 "net" 时生效;空/缺省 = 全拒,fail-closed)。
|
|
1243
|
+
netAllowlist: Array.isArray(m.netAllowlist)
|
|
1244
|
+
? m.netAllowlist
|
|
1245
|
+
.filter((x) => typeof x === "string" && x.trim().length > 0)
|
|
1246
|
+
.map((x) => x.trim().toLowerCase())
|
|
1247
|
+
.slice(0, 32)
|
|
1248
|
+
: undefined,
|
|
1249
|
+
// 引擎约束({ "pi-web-ui": ">=x.y.z" },不满足即拒绝激活,见 activate)。
|
|
1250
|
+
engines: m.engines && typeof m.engines === "object" && !Array.isArray(m.engines)
|
|
1251
|
+
? Object.fromEntries(Object.entries(m.engines)
|
|
1252
|
+
.filter(([, v]) => typeof v === "string")
|
|
1253
|
+
.slice(0, 8))
|
|
1254
|
+
: undefined,
|
|
1255
|
+
// 可选对等依赖(其它插件 id,缺失只警告不断活,见 activate)。
|
|
1256
|
+
peerPlugins: Array.isArray(m.peerPlugins)
|
|
1257
|
+
? m.peerPlugins.filter((x) => typeof x === "string" && ID_RE.test(x)).slice(0, 16)
|
|
1258
|
+
: undefined,
|
|
1259
|
+
// 特权 DOM:permissions 含 dom 族 → bundle 默认 403,需用户逐个授权。
|
|
1260
|
+
// wantsDom 缓进 domWants(静态门禁查它,不必每次 readdir);error 在未授权时
|
|
1261
|
+
// 直接写明原因(tab 置灰 + 设置行提示),授权后下次 scan 自动清除。
|
|
1262
|
+
...(wantsDom
|
|
1263
|
+
? {
|
|
1264
|
+
wantsDom: true,
|
|
1265
|
+
domGranted: this.domConsentStore.has(name),
|
|
1266
|
+
...(this.domConsentStore.has(name)
|
|
1267
|
+
? {}
|
|
1268
|
+
: {
|
|
1269
|
+
error: this.loaded.get(name)?.info.error ?? "需授权完全 DOM 访问后加载(设置 → 界面插件 → 授权)",
|
|
1270
|
+
}),
|
|
1271
|
+
}
|
|
1272
|
+
: {}),
|
|
982
1273
|
// 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
|
|
983
1274
|
settingsSchema: parseSettingsSchema(m.settings),
|
|
984
1275
|
settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings)),
|
|
@@ -986,6 +1277,16 @@ export class PluginManager {
|
|
|
986
1277
|
renderers: Array.isArray(m.renderers)
|
|
987
1278
|
? m.renderers.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
988
1279
|
: undefined,
|
|
1280
|
+
// 自定义消息部件 / 附件卡 / 输入框补全源(与 renderers 同一过滤口径)
|
|
1281
|
+
messageWidgets: Array.isArray(m.messageWidgets)
|
|
1282
|
+
? m.messageWidgets.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
1283
|
+
: undefined,
|
|
1284
|
+
attachmentCards: Array.isArray(m.attachmentCards)
|
|
1285
|
+
? m.attachmentCards.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
1286
|
+
: undefined,
|
|
1287
|
+
composerProviders: Array.isArray(m.composerProviders)
|
|
1288
|
+
? m.composerProviders.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
1289
|
+
: undefined,
|
|
989
1290
|
// 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
|
|
990
1291
|
view: typeof m.view === "boolean" ? m.view : true,
|
|
991
1292
|
// 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
|
|
@@ -1027,6 +1328,11 @@ export class PluginManager {
|
|
|
1027
1328
|
continue; // 无 manifest / JSON 坏 —— 不是插件
|
|
1028
1329
|
}
|
|
1029
1330
|
}
|
|
1331
|
+
// 删掉的目录不同时清快照:门禁会把不存在的 id 当 403,正确的应该是 404。
|
|
1332
|
+
for (const key of this.domWants.keys()) {
|
|
1333
|
+
if (!out.some((p) => p.id === key))
|
|
1334
|
+
this.domWants.delete(key);
|
|
1335
|
+
}
|
|
1030
1336
|
return out;
|
|
1031
1337
|
}
|
|
1032
1338
|
async activate(info, lang) {
|
|
@@ -1072,20 +1378,44 @@ export class PluginManager {
|
|
|
1072
1378
|
const permsDeclared = (info.permissions ?? []).slice();
|
|
1073
1379
|
const strict = permsDeclared.length > 0 || apiVersion >= 2;
|
|
1074
1380
|
const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1381
|
+
// 引擎约束(manifest engines["pi-web-ui"]):不满足即拒绝激活(与 apiVersion 超前同级处理)。
|
|
1382
|
+
// 解析失败 → 警告放行不阻断;对等依赖缺失 → 只警告不断活。
|
|
1383
|
+
const enginesReq = info.engines?.["pi-web-ui"];
|
|
1384
|
+
if (typeof enginesReq === "string" && enginesReq.trim()) {
|
|
1385
|
+
const hostVer = readHostVersion();
|
|
1386
|
+
const verdict = hostVer ? satisfiesEnginesConstraint(enginesReq, hostVer) : null;
|
|
1387
|
+
if (verdict === false) {
|
|
1388
|
+
const msg = pick(l, `插件要求 pi-web-ui ${enginesReq},当前宿主版本 ${hostVer} —— 请升级 pi-web-ui`, `Plugin requires pi-web-ui ${enginesReq} but the host is ${hostVer} — please upgrade pi-web-ui`, "plugins.host.engines.mismatch", { enginesReq, hostVer });
|
|
1389
|
+
console.error(`[plugin:${info.id}] ${msg}`);
|
|
1390
|
+
this.loaded.set(info.id, {
|
|
1391
|
+
info: { ...info, error: msg },
|
|
1392
|
+
toolHandlers,
|
|
1393
|
+
runHandlers,
|
|
1394
|
+
convChangeHandlers,
|
|
1395
|
+
attachHandlers,
|
|
1396
|
+
cwdHandlers,
|
|
1397
|
+
httpRoutes,
|
|
1398
|
+
settingsHandlers: new Set(),
|
|
1399
|
+
});
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (verdict === null) {
|
|
1403
|
+
console.warn(`[plugin:${info.id}] engines 约束「${enginesReq}」解析失败,已放行(不阻断激活)`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
for (const peer of info.peerPlugins ?? []) {
|
|
1407
|
+
if (!existsSync(join(this.pluginsDir, peer))) {
|
|
1408
|
+
console.warn(`[plugin:${info.id}] 对等插件缺失:${peer}(仅警告,不阻断激活)`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// 新增订阅的取消函数(反激活时经 releaseEntry 统一释放)。
|
|
1412
|
+
const watchSubs = [];
|
|
1413
|
+
const scheduleSubs = [];
|
|
1414
|
+
const busSubs = [];
|
|
1415
|
+
const statsSubs = [];
|
|
1416
|
+
const streamingSubs = [];
|
|
1417
|
+
// 出站网络白名单(scan 解析的 manifest netAllowlist 快照)。
|
|
1418
|
+
const netAllow = info.netAllowlist ?? [];
|
|
1089
1419
|
// 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
|
|
1090
1420
|
const storage = new PluginStorage(join(dir, "storage.json"));
|
|
1091
1421
|
const secrets = new PluginSecrets(this.dataDir, dir);
|
|
@@ -1124,22 +1454,32 @@ export class PluginManager {
|
|
|
1124
1454
|
await rm(abs, { recursive: true, force: true });
|
|
1125
1455
|
},
|
|
1126
1456
|
};
|
|
1127
|
-
|
|
1457
|
+
const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
|
|
1458
|
+
// 能力门控快照:host 对象可用之前(loaded.set 之前)canUse 也要能判定。
|
|
1459
|
+
self.activatingGates.set(info.id, { permsDeclared, permFamilies, strictMode: strict, legacyWarned: false });
|
|
1460
|
+
/** 能力门控:统一走 canUse(fs 读写分级/旧全权警告都在里面)。
|
|
1128
1461
|
* 返回 false = 已记日志,调用方应拒绝。 */
|
|
1129
|
-
const can = (family) =>
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1462
|
+
const can = (family) => self.canUse(info.id, family);
|
|
1463
|
+
/** 读门:"fs" 全开,或精确声明 "fs:read"。 */
|
|
1464
|
+
const canRead = () => self.canUse(info.id, "fs:read");
|
|
1465
|
+
/** 写门:"fs" 全开,或精确声明 "fs:write"(只读插件写即拒,提示缺 fs/fs:write)。 */
|
|
1466
|
+
const canWrite = () => self.canUse(info.id, "fs:write");
|
|
1467
|
+
/** 无头调用落地(host.chat 与 host.chatWait 共用):能力 "chat" 门控 +
|
|
1468
|
+
* 文本校验 + chatProvider 投递。宿主未接入时拒绝(不抛到插件侧,由调用方包 {ok:false})。 */
|
|
1469
|
+
const sendChat = (req) => {
|
|
1470
|
+
if (!can("chat"))
|
|
1471
|
+
return Promise.reject(new Error(`插件未声明能力 "chat"(manifest.permissions)——请求被拒`));
|
|
1472
|
+
if (!self.chatProvider) {
|
|
1473
|
+
return Promise.reject(new Error("宿主未提供无头调用(chatProvider 未接入)——请升级 pi-web-ui"));
|
|
1138
1474
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1475
|
+
const text = String(req?.text ?? "").trim();
|
|
1476
|
+
if (!text)
|
|
1477
|
+
return Promise.reject(new Error("chat: text 为空"));
|
|
1478
|
+
if (text.length > 8000)
|
|
1479
|
+
return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
|
|
1480
|
+
const accountId = String(req?.accountId ?? "default").slice(0, 64);
|
|
1481
|
+
return self.chatProvider(info.id, { text, accountId });
|
|
1141
1482
|
};
|
|
1142
|
-
const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
|
|
1143
1483
|
const host = {
|
|
1144
1484
|
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
1145
1485
|
notify: (level, text, textEn) => this.notifyAll(level, text, textEn),
|
|
@@ -1161,19 +1501,114 @@ export class PluginManager {
|
|
|
1161
1501
|
return () => convChangeHandlers.delete(h);
|
|
1162
1502
|
},
|
|
1163
1503
|
getActiveConversation: () => self.getActiveConversation(),
|
|
1164
|
-
chat: (req) =>
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1504
|
+
chat: (req) => sendChat(req),
|
|
1505
|
+
chatWait: async (req, opts) => {
|
|
1506
|
+
try {
|
|
1507
|
+
const sent = await sendChat(req);
|
|
1508
|
+
const cid = sent?.conversationId ?? "";
|
|
1509
|
+
if (!cid)
|
|
1510
|
+
return { ok: false, error: "无头调用未返回 conversationId" };
|
|
1511
|
+
// 默认 120s 超时;上下钳制防 100ms 误杀与无限等待。
|
|
1512
|
+
const timeoutMs = Math.max(1000, Math.min(Number(opts?.timeoutMs ?? 120_000) || 120_000, 600_000));
|
|
1513
|
+
const ended = await self.waitRunEnd(cid, timeoutMs);
|
|
1514
|
+
if (!ended)
|
|
1515
|
+
return { ok: false, conversationId: cid, error: `等待运行结束超时(约${Math.round(timeoutMs / 1000)}s)` };
|
|
1516
|
+
return { ok: true, conversationId: cid };
|
|
1517
|
+
}
|
|
1518
|
+
catch (err) {
|
|
1519
|
+
return { ok: false, error: err.message };
|
|
1520
|
+
}
|
|
1521
|
+
},
|
|
1522
|
+
conversations: {
|
|
1523
|
+
list: () => {
|
|
1524
|
+
try {
|
|
1525
|
+
const r = self.conversationLister?.();
|
|
1526
|
+
if (r instanceof Promise) {
|
|
1527
|
+
return r.catch((err) => {
|
|
1528
|
+
console.error(`[plugin:${info.id}] conversationLister failed:`, err);
|
|
1529
|
+
return [];
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
return r ?? [];
|
|
1533
|
+
}
|
|
1534
|
+
catch (err) {
|
|
1535
|
+
console.error(`[plugin:${info.id}] conversationLister failed:`, err);
|
|
1536
|
+
return [];
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
1539
|
+
get: (id) => {
|
|
1540
|
+
try {
|
|
1541
|
+
// conversationProvider 只给当前打开对话:id 对上才回,否则 null。
|
|
1542
|
+
const s = self.getActiveConversation();
|
|
1543
|
+
return s && s.conversationId === String(id) ? s : null;
|
|
1544
|
+
}
|
|
1545
|
+
catch {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
search: (query, limit) => {
|
|
1550
|
+
try {
|
|
1551
|
+
const q = String(query ?? "");
|
|
1552
|
+
const n = Math.max(1, Math.min(Number(limit ?? 20) || 20, 100));
|
|
1553
|
+
if (self.conversationSearcher) {
|
|
1554
|
+
const r = self.conversationSearcher(q, n);
|
|
1555
|
+
if (r instanceof Promise)
|
|
1556
|
+
return r.catch(() => []);
|
|
1557
|
+
return r ?? [];
|
|
1558
|
+
}
|
|
1559
|
+
// 无 searcher 注入时退化为 lister 标题过滤;两者都无回 []。
|
|
1560
|
+
const lq = q.trim().toLowerCase();
|
|
1561
|
+
if (!lq)
|
|
1562
|
+
return [];
|
|
1563
|
+
const pick = (arr) => arr
|
|
1564
|
+
.filter((c) => c.title.toLowerCase().includes(lq))
|
|
1565
|
+
.slice(0, n)
|
|
1566
|
+
.map((c) => ({ id: c.id, title: c.title }));
|
|
1567
|
+
const all = self.conversationLister?.();
|
|
1568
|
+
if (all instanceof Promise)
|
|
1569
|
+
return all.then(pick, () => []);
|
|
1570
|
+
return pick(all ?? []);
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
return [];
|
|
1574
|
+
}
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
prompt: async (conversationId, req) => {
|
|
1578
|
+
try {
|
|
1579
|
+
if (!self.conversationWriter)
|
|
1580
|
+
return { ok: false, error: "宿主未接入对话写入(仅标准 pi 引擎支持)" };
|
|
1581
|
+
const text = String(req?.text ?? "");
|
|
1582
|
+
if (!text.trim())
|
|
1583
|
+
return { ok: false, error: "投递文本为空" };
|
|
1584
|
+
if (text.length > 8000)
|
|
1585
|
+
return { ok: false, error: "投递文本超长(>8000 字),请裁剪后重发" };
|
|
1586
|
+
const atts = Array.isArray(req?.attachments) ? req.attachments.slice(0, 16) : undefined;
|
|
1587
|
+
return await self.conversationWriter(String(conversationId), text, atts);
|
|
1588
|
+
}
|
|
1589
|
+
catch (err) {
|
|
1590
|
+
return { ok: false, error: err.message };
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
steer: async (conversationId, text) => {
|
|
1594
|
+
try {
|
|
1595
|
+
if (!self.runSteerer)
|
|
1596
|
+
return { ok: false, error: "宿主未接入运行插队(仅标准 pi 引擎支持)" };
|
|
1597
|
+
return await self.runSteerer(String(conversationId), String(text ?? ""));
|
|
1598
|
+
}
|
|
1599
|
+
catch (err) {
|
|
1600
|
+
return { ok: false, error: err.message };
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
abortRun: async (conversationId) => {
|
|
1604
|
+
try {
|
|
1605
|
+
if (!self.runAborter)
|
|
1606
|
+
return { ok: false, error: "宿主未接入运行中止(仅标准 pi 引擎支持)" };
|
|
1607
|
+
return await self.runAborter(String(conversationId));
|
|
1608
|
+
}
|
|
1609
|
+
catch (err) {
|
|
1610
|
+
return { ok: false, error: err.message };
|
|
1169
1611
|
}
|
|
1170
|
-
const text = String(req?.text ?? "").trim();
|
|
1171
|
-
if (!text)
|
|
1172
|
-
return Promise.reject(new Error("chat: text 为空"));
|
|
1173
|
-
if (text.length > 8000)
|
|
1174
|
-
return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
|
|
1175
|
-
const accountId = String(req?.accountId ?? "default").slice(0, 64);
|
|
1176
|
-
return self.chatProvider(info.id, { text, accountId });
|
|
1177
1612
|
},
|
|
1178
1613
|
onAttach: (h) => {
|
|
1179
1614
|
attachHandlers.add(h);
|
|
@@ -1229,13 +1664,13 @@ export class PluginManager {
|
|
|
1229
1664
|
return self.cwdValue;
|
|
1230
1665
|
},
|
|
1231
1666
|
fs: {
|
|
1232
|
-
list: (relDir) => (
|
|
1233
|
-
read: (p) => (
|
|
1234
|
-
readText: (p, max) => (
|
|
1235
|
-
write: (p, data) => (
|
|
1236
|
-
remove: (p) => (
|
|
1667
|
+
list: (relDir) => (canRead() ? workspaceFs.list(relDir) : NO_FS_PROMISE),
|
|
1668
|
+
read: (p) => (canRead() ? workspaceFs.read(p) : NO_FS_PROMISE),
|
|
1669
|
+
readText: (p, max) => (canRead() ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
|
|
1670
|
+
write: (p, data) => (canWrite() ? workspaceFs.write(p, data) : NO_FS_WRITE_PROMISE),
|
|
1671
|
+
remove: (p) => (canWrite() ? workspaceFs.remove(p) : NO_FS_WRITE_PROMISE),
|
|
1237
1672
|
requestAccess: async (dir, reason) => {
|
|
1238
|
-
if (!
|
|
1673
|
+
if (!canRead())
|
|
1239
1674
|
return false;
|
|
1240
1675
|
const abs = normalizeGrantPath(String(dir ?? ""));
|
|
1241
1676
|
if (!abs)
|
|
@@ -1260,17 +1695,52 @@ export class PluginManager {
|
|
|
1260
1695
|
}
|
|
1261
1696
|
return ok;
|
|
1262
1697
|
},
|
|
1263
|
-
authorizedDirs: () => (
|
|
1264
|
-
listPath: (absDir) => (
|
|
1265
|
-
readPath: (absPath) => (
|
|
1266
|
-
readTextPath: (absPath, max) => (
|
|
1267
|
-
writePath: (absPath, data) => (
|
|
1268
|
-
removePath: (absPath) => (
|
|
1698
|
+
authorizedDirs: () => (canRead() ? self.grants.get(info.id) : []),
|
|
1699
|
+
listPath: (absDir) => (canRead() ? crossDirFs.list(absDir) : NO_FS_PROMISE),
|
|
1700
|
+
readPath: (absPath) => (canRead() ? crossDirFs.read(absPath) : NO_FS_PROMISE),
|
|
1701
|
+
readTextPath: (absPath, max) => (canRead() ? crossDirFs.readText(absPath, max) : NO_FS_PROMISE),
|
|
1702
|
+
writePath: (absPath, data) => (canWrite() ? crossDirFs.write(absPath, data) : NO_FS_WRITE_PROMISE),
|
|
1703
|
+
removePath: (absPath) => (canWrite() ? crossDirFs.remove(absPath) : NO_FS_WRITE_PROMISE),
|
|
1704
|
+
watch: (relPath, handler) => {
|
|
1705
|
+
if (!canRead())
|
|
1706
|
+
throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
|
|
1707
|
+
if (typeof handler !== "function")
|
|
1708
|
+
throw new Error("watch: handler 必须是函数");
|
|
1709
|
+
// 锚定活 cwd 根:目标必须在工作区内(复用 WorkspaceFS 的越界校验思想)。
|
|
1710
|
+
const target = resolve(self.cwdValue, String(relPath ?? ""));
|
|
1711
|
+
if (!self.isInsideWorkspace(target))
|
|
1712
|
+
throw new Error(`路径越界:${String(relPath)}`);
|
|
1713
|
+
const watcher = fsWatch(target, (eventType, filename) => {
|
|
1714
|
+
try {
|
|
1715
|
+
handler({ type: String(eventType), path: String(filename ?? relPath) });
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
console.error(`[plugin:${info.id}] watch handler failed:`, err);
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
watcher.on("error", (err) => console.error(`[plugin:${info.id}] watch failed:`, err));
|
|
1722
|
+
const off = () => {
|
|
1723
|
+
try {
|
|
1724
|
+
watcher.close();
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
/* already closed */
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
watchSubs.push(off);
|
|
1731
|
+
return off;
|
|
1732
|
+
},
|
|
1269
1733
|
},
|
|
1270
1734
|
project: {
|
|
1271
1735
|
create: async (spec) => {
|
|
1272
|
-
|
|
1273
|
-
|
|
1736
|
+
// 写门:只读插件(仅 fs:read)与无 fs 声明的一律拒绝,错误提示缺 fs/fs:write。
|
|
1737
|
+
if (!canWrite())
|
|
1738
|
+
return {
|
|
1739
|
+
ok: false,
|
|
1740
|
+
error: '缺少写能力:project.create 需要 "fs"/"fs:write"(manifest.permissions)',
|
|
1741
|
+
log: [],
|
|
1742
|
+
dir: "",
|
|
1743
|
+
};
|
|
1274
1744
|
const dir = normalizeGrantPath(String(spec?.dir ?? ""));
|
|
1275
1745
|
if (!dir)
|
|
1276
1746
|
return { ok: false, error: "项目目录必须是绝对路径", log: [], dir: "" };
|
|
@@ -1387,6 +1857,231 @@ export class PluginManager {
|
|
|
1387
1857
|
settingsHandlers.add(h);
|
|
1388
1858
|
return () => settingsHandlers.delete(h);
|
|
1389
1859
|
},
|
|
1860
|
+
scm: {
|
|
1861
|
+
status: async () => {
|
|
1862
|
+
if (!canRead())
|
|
1863
|
+
throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
|
|
1864
|
+
try {
|
|
1865
|
+
const mod = await import("./scm.js");
|
|
1866
|
+
return await mod.scmStatus(self.cwdValue);
|
|
1867
|
+
}
|
|
1868
|
+
catch (err) {
|
|
1869
|
+
return { ok: false, error: err.message };
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
log: async (path, limit) => {
|
|
1873
|
+
if (!canRead())
|
|
1874
|
+
throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
|
|
1875
|
+
try {
|
|
1876
|
+
const mod = await import("./scm.js");
|
|
1877
|
+
const all = await mod.scmHistory(self.cwdValue);
|
|
1878
|
+
const n = Math.max(1, Math.min(Number(limit ?? 50) || 50, 200));
|
|
1879
|
+
return all.slice(0, n);
|
|
1880
|
+
}
|
|
1881
|
+
catch (err) {
|
|
1882
|
+
return { ok: false, error: err.message };
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
},
|
|
1886
|
+
bash: async (cmd, opts) => {
|
|
1887
|
+
// 门控语义沿用 registerAgentTool:无 "tools" 声明即拒绝(结果对象形态,不断路抛错)。
|
|
1888
|
+
if (!can("tools"))
|
|
1889
|
+
return { ok: false, output: "", error: '插件未声明能力 "tools"(manifest.permissions)——请求被拒' };
|
|
1890
|
+
try {
|
|
1891
|
+
const parts = String(cmd ?? "")
|
|
1892
|
+
.trim()
|
|
1893
|
+
.split(/\s+/)
|
|
1894
|
+
.filter(Boolean);
|
|
1895
|
+
const file = parts[0];
|
|
1896
|
+
if (!file)
|
|
1897
|
+
return { ok: false, output: "", error: "bash: cmd 为空" };
|
|
1898
|
+
const cwd = opts?.cwd ? resolve(self.cwdValue, opts.cwd) : self.cwdValue;
|
|
1899
|
+
if (!self.isInsideWorkspace(cwd))
|
|
1900
|
+
return { ok: false, output: "", error: `工作目录越界:${opts?.cwd}` };
|
|
1901
|
+
const timeout = Math.max(1000, Math.min(Number(opts?.timeoutMs ?? 60_000) || 60_000, 600_000));
|
|
1902
|
+
const { stdout, stderr } = await execFileAsync(file, parts.slice(1), {
|
|
1903
|
+
cwd,
|
|
1904
|
+
timeout,
|
|
1905
|
+
windowsHide: true,
|
|
1906
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1907
|
+
encoding: "utf8",
|
|
1908
|
+
});
|
|
1909
|
+
const output = `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.slice(0, 256 * 1024);
|
|
1910
|
+
return { ok: true, output, exitCode: 0 };
|
|
1911
|
+
}
|
|
1912
|
+
catch (err) {
|
|
1913
|
+
const e = err;
|
|
1914
|
+
const out = [typeof e.stdout === "string" ? e.stdout : "", typeof e.stderr === "string" ? e.stderr : ""]
|
|
1915
|
+
.filter(Boolean)
|
|
1916
|
+
.join("\n")
|
|
1917
|
+
.slice(0, 256 * 1024);
|
|
1918
|
+
return {
|
|
1919
|
+
ok: false,
|
|
1920
|
+
output: out,
|
|
1921
|
+
...(typeof e.code === "number" ? { exitCode: e.code } : {}),
|
|
1922
|
+
error: String(e.message ?? err).slice(0, 2000),
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
},
|
|
1926
|
+
schedule: (cronOrMs, fn) => {
|
|
1927
|
+
if (typeof fn !== "function")
|
|
1928
|
+
throw new Error("schedule: fn 必须是函数");
|
|
1929
|
+
let ms;
|
|
1930
|
+
if (typeof cronOrMs === "number") {
|
|
1931
|
+
ms = Math.max(10_000, Math.floor(cronOrMs) || 10_000);
|
|
1932
|
+
}
|
|
1933
|
+
else if (typeof cronOrMs === "string") {
|
|
1934
|
+
const m = cronOrMs.trim().match(/^\*\/(\d+)\s+\*\s+\*\s+\*\s+\*$/);
|
|
1935
|
+
if (!m)
|
|
1936
|
+
throw new Error(`schedule: 不支持的 cron 形状「${cronOrMs}」(仅支持 "*/N * * * *" 分钟步长)`);
|
|
1937
|
+
ms = Math.max(10_000, Number(m[1]) * 60_000);
|
|
1938
|
+
}
|
|
1939
|
+
else {
|
|
1940
|
+
throw new Error("schedule: 参数必须是间隔毫秒数或 cron 字符串");
|
|
1941
|
+
}
|
|
1942
|
+
ms = Math.min(ms, 2_147_483_647); // setInterval 上限(约 24.8 天),防溢出立即触发
|
|
1943
|
+
const timer = setInterval(() => {
|
|
1944
|
+
try {
|
|
1945
|
+
fn();
|
|
1946
|
+
}
|
|
1947
|
+
catch (err) {
|
|
1948
|
+
console.error(`[plugin:${info.id}] scheduled task failed:`, err);
|
|
1949
|
+
}
|
|
1950
|
+
}, ms);
|
|
1951
|
+
timer.unref?.();
|
|
1952
|
+
const off = () => clearInterval(timer);
|
|
1953
|
+
scheduleSubs.push(off);
|
|
1954
|
+
return off;
|
|
1955
|
+
},
|
|
1956
|
+
models: {
|
|
1957
|
+
list: () => {
|
|
1958
|
+
try {
|
|
1959
|
+
const r = self.modelLister?.();
|
|
1960
|
+
if (r instanceof Promise) {
|
|
1961
|
+
return r.catch((err) => {
|
|
1962
|
+
console.error(`[plugin:${info.id}] modelLister failed:`, err);
|
|
1963
|
+
return [];
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
return r ?? [];
|
|
1967
|
+
}
|
|
1968
|
+
catch (err) {
|
|
1969
|
+
console.error(`[plugin:${info.id}] modelLister failed:`, err);
|
|
1970
|
+
return [];
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
},
|
|
1974
|
+
onStats: (h) => {
|
|
1975
|
+
self.statsHandlers.add(h);
|
|
1976
|
+
const off = () => {
|
|
1977
|
+
self.statsHandlers.delete(h);
|
|
1978
|
+
};
|
|
1979
|
+
statsSubs.push(off);
|
|
1980
|
+
return () => {
|
|
1981
|
+
const i = statsSubs.indexOf(off);
|
|
1982
|
+
if (i >= 0)
|
|
1983
|
+
statsSubs.splice(i, 1);
|
|
1984
|
+
off();
|
|
1985
|
+
};
|
|
1986
|
+
},
|
|
1987
|
+
onStreaming: (h) => {
|
|
1988
|
+
self.streamingHandlers.add(h);
|
|
1989
|
+
const off = () => {
|
|
1990
|
+
self.streamingHandlers.delete(h);
|
|
1991
|
+
};
|
|
1992
|
+
streamingSubs.push(off);
|
|
1993
|
+
return () => {
|
|
1994
|
+
const i = streamingSubs.indexOf(off);
|
|
1995
|
+
if (i >= 0)
|
|
1996
|
+
streamingSubs.splice(i, 1);
|
|
1997
|
+
off();
|
|
1998
|
+
};
|
|
1999
|
+
},
|
|
2000
|
+
net: {
|
|
2001
|
+
fetch: async (url, init) => {
|
|
2002
|
+
if (!can("net"))
|
|
2003
|
+
return { ok: false, error: '插件未声明能力 "net"(manifest.permissions)——请求被拒' };
|
|
2004
|
+
try {
|
|
2005
|
+
const u = new URL(String(url));
|
|
2006
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
2007
|
+
return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
|
|
2008
|
+
}
|
|
2009
|
+
// 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。
|
|
2010
|
+
const hostname = u.hostname.toLowerCase();
|
|
2011
|
+
const allowed = netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`));
|
|
2012
|
+
if (!allowed)
|
|
2013
|
+
return { ok: false, error: `net: 主机 ${u.hostname} 不在白名单(manifest.netAllowlist)` };
|
|
2014
|
+
if (init?.body !== undefined && Buffer.byteLength(String(init.body), "utf8") > 1024 * 1024) {
|
|
2015
|
+
return { ok: false, error: "net: body 超过 1MB 上限" };
|
|
2016
|
+
}
|
|
2017
|
+
const res = await globalThis.fetch(String(url), {
|
|
2018
|
+
method: init?.method ?? "GET",
|
|
2019
|
+
...(init?.headers ? { headers: init.headers } : {}),
|
|
2020
|
+
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
2021
|
+
signal: AbortSignal.timeout(15_000),
|
|
2022
|
+
});
|
|
2023
|
+
const text = (await res.text()).slice(0, 512 * 1024);
|
|
2024
|
+
return { ok: true, status: res.status, text };
|
|
2025
|
+
}
|
|
2026
|
+
catch (err) {
|
|
2027
|
+
return { ok: false, error: err.message };
|
|
2028
|
+
}
|
|
2029
|
+
},
|
|
2030
|
+
},
|
|
2031
|
+
events: {
|
|
2032
|
+
emit: (topic, payload) => {
|
|
2033
|
+
const t = String(topic ?? "");
|
|
2034
|
+
if (!t)
|
|
2035
|
+
return;
|
|
2036
|
+
// 载荷 JSON 往返截断:4KB 内还原对象,超了取截断后的字符串化形态。
|
|
2037
|
+
let p;
|
|
2038
|
+
try {
|
|
2039
|
+
const s = JSON.stringify(payload);
|
|
2040
|
+
p = s.length > 4096 ? s.slice(0, 4096) : JSON.parse(s);
|
|
2041
|
+
}
|
|
2042
|
+
catch {
|
|
2043
|
+
try {
|
|
2044
|
+
p = String(payload).slice(0, 4096);
|
|
2045
|
+
}
|
|
2046
|
+
catch {
|
|
2047
|
+
p = undefined;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
const ev = { topic: t, from: info.id, payload: p };
|
|
2051
|
+
const handlers = self.busHandlers.get(t);
|
|
2052
|
+
if (!handlers)
|
|
2053
|
+
return;
|
|
2054
|
+
for (const h of [...handlers]) {
|
|
2055
|
+
try {
|
|
2056
|
+
h(ev);
|
|
2057
|
+
}
|
|
2058
|
+
catch (err) {
|
|
2059
|
+
console.error(`[plugin:${info.id}] bus handler failed:`, err);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
},
|
|
2063
|
+
on: (topic, handler) => {
|
|
2064
|
+
const t = String(topic ?? "");
|
|
2065
|
+
if (!t || typeof handler !== "function")
|
|
2066
|
+
return () => { };
|
|
2067
|
+
let set = self.busHandlers.get(t);
|
|
2068
|
+
if (!set)
|
|
2069
|
+
self.busHandlers.set(t, (set = new Set()));
|
|
2070
|
+
set.add(handler);
|
|
2071
|
+
const off = () => {
|
|
2072
|
+
set.delete(handler);
|
|
2073
|
+
if (set.size === 0)
|
|
2074
|
+
self.busHandlers.delete(t);
|
|
2075
|
+
};
|
|
2076
|
+
busSubs.push(off);
|
|
2077
|
+
return () => {
|
|
2078
|
+
const i = busSubs.indexOf(off);
|
|
2079
|
+
if (i >= 0)
|
|
2080
|
+
busSubs.splice(i, 1);
|
|
2081
|
+
off();
|
|
2082
|
+
};
|
|
2083
|
+
},
|
|
2084
|
+
},
|
|
1390
2085
|
log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
|
|
1391
2086
|
};
|
|
1392
2087
|
try {
|
|
@@ -1394,6 +2089,8 @@ export class PluginManager {
|
|
|
1394
2089
|
// 击穿缓存,让 plugins_reload 后的重新激活能拿到磁盘上的新代码。
|
|
1395
2090
|
const mod = (await import(pathToFileURL(join(dir, "index.mjs")).href + `?e=${this.epochCounter}`));
|
|
1396
2091
|
const ret = await mod.default?.activate?.(host);
|
|
2092
|
+
const gateWarned = self.activatingGates.get(info.id)?.legacyWarned;
|
|
2093
|
+
self.activatingGates.delete(info.id);
|
|
1397
2094
|
this.loaded.set(info.id, {
|
|
1398
2095
|
info: { ...info },
|
|
1399
2096
|
deactivate: typeof ret === "function" ? ret : undefined,
|
|
@@ -1404,7 +2101,16 @@ export class PluginManager {
|
|
|
1404
2101
|
cwdHandlers,
|
|
1405
2102
|
agentToolUnsubscribers: unregisterTools,
|
|
1406
2103
|
commandUnsubscribers: unregisterCommands,
|
|
2104
|
+
watchUnsubscribers: watchSubs,
|
|
2105
|
+
scheduleUnsubscribers: scheduleSubs,
|
|
2106
|
+
busUnsubscribers: busSubs,
|
|
2107
|
+
statsUnsubscribers: statsSubs,
|
|
2108
|
+
streamingUnsubscribers: streamingSubs,
|
|
1407
2109
|
httpRoutes,
|
|
2110
|
+
permsDeclared,
|
|
2111
|
+
permFamilies,
|
|
2112
|
+
strictMode: strict,
|
|
2113
|
+
legacyWarned: gateWarned,
|
|
1408
2114
|
settingsHandlers,
|
|
1409
2115
|
});
|
|
1410
2116
|
console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
|
|
@@ -1415,6 +2121,7 @@ export class PluginManager {
|
|
|
1415
2121
|
}
|
|
1416
2122
|
catch (err) {
|
|
1417
2123
|
httpRoutes.clear();
|
|
2124
|
+
self.activatingGates.delete(info.id);
|
|
1418
2125
|
this.loaded.set(info.id, {
|
|
1419
2126
|
info: { ...info, error: err.message },
|
|
1420
2127
|
toolHandlers,
|