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/plugins.js
CHANGED
|
@@ -21,16 +21,20 @@ import { existsSync, readFileSync, renameSync, watch as fsWatch, writeFileSync }
|
|
|
21
21
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
22
22
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
23
23
|
import { pick } from "./i18n.js";
|
|
24
|
-
import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
|
|
24
|
+
import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS, withFileRmwLock } from "./plugin-facilities.js";
|
|
25
25
|
import { parseCronSpec, armDelay, nextCronFire, loadScheduleRecords, saveScheduleRecords, } from "./plugin-schedule.js";
|
|
26
26
|
import { PluginPermissionStore } from "./plugin-permissions.js";
|
|
27
27
|
import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
|
|
28
28
|
import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
|
|
29
29
|
import { normalizeIconSvg } from "./icon-svg.js";
|
|
30
30
|
import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
|
|
31
|
+
import { validatePluginManifest, formatManifestIssue, isKnownPermission } from "./plugin-manifest-validate.js";
|
|
32
|
+
import { buildPluginApiCatalog } from "./plugin-api-catalog.js";
|
|
33
|
+
import { AGENT_TOOL_CATALOG } from "./tool-manager.js";
|
|
34
|
+
import { applyPostEdit, denialText, freezeParams, isBlockingDecision, normalizePostEdit, normalizePreDecision, withGuardTimeout, } from "./plugin-tool-guard.js";
|
|
31
35
|
// 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
|
|
32
36
|
import { normalizeWorkspaceRoots } from "./client-state.js";
|
|
33
|
-
import { createProject } from "./plugin-project.js";
|
|
37
|
+
import { createProject, isInsideRoot, realPathOfNearest, } from "./plugin-project.js";
|
|
34
38
|
import { createHash } from "node:crypto";
|
|
35
39
|
import { execFile } from "node:child_process";
|
|
36
40
|
import { promisify } from "node:util";
|
|
@@ -79,6 +83,81 @@ export function isPluginLogsRequest(p) {
|
|
|
79
83
|
const o = p;
|
|
80
84
|
return o[PLUGIN_LOGS_HOST_KEY] === "logs" && (o.op === "get" || o.op === "clear" || o.op === undefined);
|
|
81
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* per-plugin 可逆副作用栈(effect 栈)。
|
|
88
|
+
*
|
|
89
|
+
* 宿主每个注册面(事件订阅 / AI 工具 / 命令 / HTTP 路由 / 反向代理 / fs.watch /
|
|
90
|
+
* 定时任务 / 后台任务 / UI 条目 / 设置回调…)在内部 `add(label, dispose)` 登记一条
|
|
91
|
+
* disposer,反激活时**逆序**回卷 —— 插件不必记得注销,也不会因为漏掉一处就留下
|
|
92
|
+
* 孤儿订阅、定时器、路由或事件处理器(热重载后事件双触发、定时器叠加、watcher
|
|
93
|
+
* 堆积都是这个漏法的症状)。
|
|
94
|
+
*
|
|
95
|
+
* 与「每个注册函数自己返回注销函数」(插件主动调用)是**同一件事的两面**:
|
|
96
|
+
* 返回给插件的注销函数 = `remove(item)`(只撤这一条、幂等),反激活 = `release()`。
|
|
97
|
+
* 插件自建的副作用(自己的 setInterval、EventEmitter 监听…)可经 `host.effect()`
|
|
98
|
+
* 挂进来,同样享受逆序回卷 + 泄漏归因。
|
|
99
|
+
*/
|
|
100
|
+
export class PluginEffectStack {
|
|
101
|
+
pluginId;
|
|
102
|
+
diag;
|
|
103
|
+
items = [];
|
|
104
|
+
released = false;
|
|
105
|
+
constructor(pluginId,
|
|
106
|
+
/** 记录一条诊断(cleanup 抛错时调用;不抛错、不阻断回卷)。 */
|
|
107
|
+
diag) {
|
|
108
|
+
this.pluginId = pluginId;
|
|
109
|
+
this.diag = diag;
|
|
110
|
+
}
|
|
111
|
+
/** 登记一条副作用;返回「只撤这一条」的注销函数(幂等,重复调用无副作用)。 */
|
|
112
|
+
add(label, dispose) {
|
|
113
|
+
const item = { label, dispose };
|
|
114
|
+
this.items.push(item);
|
|
115
|
+
return () => this.remove(item);
|
|
116
|
+
}
|
|
117
|
+
/** 当前未回卷的副作用数(单测断言「activate → deactivate 后栈空」)。 */
|
|
118
|
+
get size() {
|
|
119
|
+
return this.items.length;
|
|
120
|
+
}
|
|
121
|
+
/** 未回卷的副作用标签(按登记顺序,排障用)。 */
|
|
122
|
+
labels() {
|
|
123
|
+
return this.items.map((i) => i.label);
|
|
124
|
+
}
|
|
125
|
+
/** 撤销单条:先从栈里摘掉,再跑它的 dispose(幂等 —— 摘不到说明已撤过)。 */
|
|
126
|
+
remove(item) {
|
|
127
|
+
const i = this.items.indexOf(item);
|
|
128
|
+
if (i < 0)
|
|
129
|
+
return;
|
|
130
|
+
this.items.splice(i, 1);
|
|
131
|
+
try {
|
|
132
|
+
item.dispose();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.report(item.label, err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** 逆序回卷全部副作用;返回**失败**(dispose 抛错)的标签列表,绝不抛出。 */
|
|
139
|
+
release() {
|
|
140
|
+
if (this.released)
|
|
141
|
+
return [];
|
|
142
|
+
this.released = true;
|
|
143
|
+
const failed = [];
|
|
144
|
+
while (this.items.length > 0) {
|
|
145
|
+
const item = this.items.pop();
|
|
146
|
+
try {
|
|
147
|
+
item.dispose();
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
failed.push(item.label);
|
|
151
|
+
this.report(item.label, err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return failed;
|
|
155
|
+
}
|
|
156
|
+
report(label, err) {
|
|
157
|
+
console.warn(`[plugin:${this.pluginId}] effect "${label}" cleanup failed:`, err);
|
|
158
|
+
this.diag?.(`effect "${label}" cleanup failed: ${err?.message ?? String(err)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
82
161
|
/** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
|
|
83
162
|
* 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。
|
|
84
163
|
* 1 = 初始:storage/secrets/命令/HTTP 路由/工具注册/受限 fs/后台任务。
|
|
@@ -173,7 +252,7 @@ const SETTING_OPTIONS_FROM = new Set(["models", "thinkingLevels"]);
|
|
|
173
252
|
* 不认识的 slot / kind / when 直接丢弃(旧宿主读到新字段也不会崩,新宿主读到旧字段同理)。
|
|
174
253
|
* 归属由宿主决定:全局 id = `<pluginId>:<itemId>`。
|
|
175
254
|
*/
|
|
176
|
-
const UI_SLOTS = new Set([
|
|
255
|
+
export const UI_SLOTS = new Set([
|
|
177
256
|
"topbar.primary",
|
|
178
257
|
"topbar.overflow",
|
|
179
258
|
"bottombar",
|
|
@@ -198,7 +277,7 @@ const UI_SLOTS = new Set([
|
|
|
198
277
|
"modal.dialog",
|
|
199
278
|
]);
|
|
200
279
|
/** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
|
|
201
|
-
const UI_SLOT_ALIASES = {
|
|
280
|
+
export const UI_SLOT_ALIASES = {
|
|
202
281
|
topbar: "topbar.primary",
|
|
203
282
|
"topbar.more": "topbar.overflow",
|
|
204
283
|
composer: "composer.actions",
|
|
@@ -208,7 +287,7 @@ const UI_SLOT_ALIASES = {
|
|
|
208
287
|
modal: "modal.dialog",
|
|
209
288
|
};
|
|
210
289
|
/** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
|
|
211
|
-
const UI_KINDS = new Set([
|
|
290
|
+
export const UI_KINDS = new Set([
|
|
212
291
|
"view",
|
|
213
292
|
"action",
|
|
214
293
|
"badge",
|
|
@@ -554,6 +633,69 @@ export function parseUiArrange(raw, diagnostics) {
|
|
|
554
633
|
}
|
|
555
634
|
return out;
|
|
556
635
|
}
|
|
636
|
+
/** 解析 manifest.requires → 归一化硬依赖声明(P2-8,坏形状宽容忽略:
|
|
637
|
+
* 形状错在 P1-6 校验层已拒,这里只管把合法部分抽出来供展示 + activate 判定)。 */
|
|
638
|
+
export function parseRequires(raw) {
|
|
639
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
640
|
+
return undefined;
|
|
641
|
+
const o = raw;
|
|
642
|
+
const out = {};
|
|
643
|
+
if (typeof o.hostApi === "number" && Number.isInteger(o.hostApi) && o.hostApi >= 1)
|
|
644
|
+
out.hostApi = o.hostApi;
|
|
645
|
+
if (Array.isArray(o.families)) {
|
|
646
|
+
const fams = o.families
|
|
647
|
+
.filter((x) => typeof x === "string" && x.trim().length > 0)
|
|
648
|
+
.map((x) => x.trim())
|
|
649
|
+
.slice(0, 8);
|
|
650
|
+
if (fams.length)
|
|
651
|
+
out.families = fams;
|
|
652
|
+
}
|
|
653
|
+
if (Array.isArray(o.plugins)) {
|
|
654
|
+
const deps = o.plugins
|
|
655
|
+
.filter((x) => typeof x === "string" && ID_RE.test(x.trim()))
|
|
656
|
+
.map((x) => x.trim())
|
|
657
|
+
.slice(0, 16);
|
|
658
|
+
if (deps.length)
|
|
659
|
+
out.plugins = deps;
|
|
660
|
+
}
|
|
661
|
+
return out.hostApi !== undefined || out.families !== undefined || out.plugins !== undefined ? out : undefined;
|
|
662
|
+
}
|
|
663
|
+
/** 依赖拓扑排序(P2-8):按 requires.plugins 把被依赖者排前面;同层保持扫描顺序。
|
|
664
|
+
* 返回 { order }(可激活顺序)+ { cyclic }(环/挂在环上的,调用方直接拒)。
|
|
665
|
+
* 边只连「本次扫描里存在」的对等端;缺失的对端由 activate 判定点名拒绝。 */
|
|
666
|
+
export function sortByRequires(ids, depsOf) {
|
|
667
|
+
const indeg = new Map();
|
|
668
|
+
const edges = new Map();
|
|
669
|
+
const idSet = new Set(ids);
|
|
670
|
+
for (const id of ids) {
|
|
671
|
+
indeg.set(id, 0);
|
|
672
|
+
edges.set(id, []);
|
|
673
|
+
}
|
|
674
|
+
for (const id of ids) {
|
|
675
|
+
const seen = new Set();
|
|
676
|
+
for (const dep of depsOf(id)) {
|
|
677
|
+
if (!idSet.has(dep) || dep === id || seen.has(dep))
|
|
678
|
+
continue;
|
|
679
|
+
seen.add(dep);
|
|
680
|
+
edges.get(dep).push(id);
|
|
681
|
+
indeg.set(id, (indeg.get(id) ?? 0) + 1);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0);
|
|
685
|
+
const order = [];
|
|
686
|
+
while (queue.length) {
|
|
687
|
+
const cur = queue.shift();
|
|
688
|
+
order.push(cur);
|
|
689
|
+
for (const next of edges.get(cur) ?? []) {
|
|
690
|
+
const d = (indeg.get(next) ?? 1) - 1;
|
|
691
|
+
indeg.set(next, d);
|
|
692
|
+
if (d === 0)
|
|
693
|
+
queue.push(next);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const ordered = new Set(order);
|
|
697
|
+
return { order, cyclic: ids.filter((id) => !ordered.has(id)) };
|
|
698
|
+
}
|
|
557
699
|
/** 合并 manifest 基线与运行时注册:运行时同 id 覆盖,removed 里的删除;arrange 追加。 */
|
|
558
700
|
function mergeUiPluginUi(base, rt) {
|
|
559
701
|
const items = new Map();
|
|
@@ -607,53 +749,161 @@ function parseSettingsSchema(raw) {
|
|
|
607
749
|
function secretSettingKey(key) {
|
|
608
750
|
return `setting:${key}`;
|
|
609
751
|
}
|
|
610
|
-
/**
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
function
|
|
614
|
-
const
|
|
615
|
-
|
|
752
|
+
/** 用户 overlay 原文件(P2-9):`<dataDir>/plugin-overrides/<id>.json` 的 settings 节。
|
|
753
|
+
* 缺文件 = 没写(不警告);坏形状 → 警告并整体忽略(用户自己的文件,不连坐插件)。
|
|
754
|
+
* 顶层未知键忽略(前向兼容);每键校验在 cleanOverlaySettings 里按 schema 做。 */
|
|
755
|
+
function readSettingsOverlayFile(dataDir, pluginId) {
|
|
756
|
+
const empty = { raw: {}, warnings: [] };
|
|
757
|
+
if (!ID_RE.test(pluginId))
|
|
758
|
+
return empty;
|
|
759
|
+
let parsed;
|
|
616
760
|
try {
|
|
617
|
-
|
|
618
|
-
if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
|
|
619
|
-
stored = parsed.settings;
|
|
620
|
-
}
|
|
761
|
+
parsed = JSON.parse(readFileSync(join(dataDir, "plugin-overrides", `${pluginId}.json`), "utf8"));
|
|
621
762
|
}
|
|
622
763
|
catch {
|
|
623
|
-
|
|
764
|
+
return empty;
|
|
765
|
+
}
|
|
766
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
767
|
+
return { raw: {}, warnings: [`settings override ignored: ${pluginId}.json must be an object`] };
|
|
768
|
+
}
|
|
769
|
+
const o = parsed;
|
|
770
|
+
if (o.settings === undefined)
|
|
771
|
+
return empty;
|
|
772
|
+
if (!o.settings || typeof o.settings !== "object" || Array.isArray(o.settings)) {
|
|
773
|
+
return { raw: {}, warnings: [`settings override ignored: ${pluginId}.json settings must be an object`] };
|
|
624
774
|
}
|
|
775
|
+
return { raw: o.settings, warnings: [] };
|
|
776
|
+
}
|
|
777
|
+
/** 按 schema 清洗 overlay 值(P2-9):secret 一律拒绝(明文永不进加密 store)+
|
|
778
|
+
* 诊断;类型不对/越界/非法候选 → 警告并丢该键;schema 外的键 → 警告并忽略(拼写检查)。 */
|
|
779
|
+
function cleanOverlaySettings(schema, raw, lang) {
|
|
780
|
+
const values = {};
|
|
781
|
+
const warnings = [];
|
|
782
|
+
const byKey = new Map(schema.map((f) => [f.key, f]));
|
|
783
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
784
|
+
const f = byKey.get(k);
|
|
785
|
+
if (!f) {
|
|
786
|
+
warnings.push(`settings override ignored: unknown key "${k.slice(0, 64)}"`);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (f.type === "secret") {
|
|
790
|
+
warnings.push(`settings override ignored: secret "${k.slice(0, 64)}" never comes from overlay`);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const r = cleanNonSecretSettingsField(f, v, lang);
|
|
794
|
+
if (r.error) {
|
|
795
|
+
warnings.push(`settings override ignored: "${k.slice(0, 64)}": ${r.error}`);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
values[k] = r.value;
|
|
799
|
+
}
|
|
800
|
+
return { values, warnings };
|
|
801
|
+
}
|
|
802
|
+
/** 三层合并(P2-9):schema 默认 < overlay(用户钉住的新默认,不 fork)< storage.json
|
|
803
|
+
* (面板保存,最高)。stored 不清洗(保存路径已洗过,可信);overlay 已预洗。
|
|
804
|
+
* secret 走加密 store,overlay 永不参与。 */
|
|
805
|
+
function resolveSettingsValues(schema, stored, overlay, secrets) {
|
|
806
|
+
const values = {};
|
|
807
|
+
const sources = {};
|
|
625
808
|
for (const f of schema) {
|
|
626
809
|
if (f.type === "secret") {
|
|
627
|
-
|
|
628
|
-
|
|
810
|
+
if (secrets?.get) {
|
|
811
|
+
const got = secrets.get(secretSettingKey(f.key));
|
|
812
|
+
values[f.key] = got ?? f.default ?? "";
|
|
813
|
+
sources[f.key] = got !== undefined ? "stored" : "default";
|
|
814
|
+
}
|
|
815
|
+
else if (secrets?.has) {
|
|
816
|
+
const has = secrets.has(secretSettingKey(f.key));
|
|
817
|
+
values[f.key] = has;
|
|
818
|
+
sources[f.key] = has ? "stored" : "default";
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
values[f.key] = false;
|
|
822
|
+
sources[f.key] = "default";
|
|
823
|
+
}
|
|
629
824
|
continue;
|
|
630
825
|
}
|
|
631
|
-
|
|
826
|
+
const s = stored[f.key];
|
|
827
|
+
if (s !== undefined && s !== null) {
|
|
828
|
+
values[f.key] = s;
|
|
829
|
+
sources[f.key] = "stored";
|
|
830
|
+
}
|
|
831
|
+
else if (f.key in overlay) {
|
|
832
|
+
values[f.key] = overlay[f.key];
|
|
833
|
+
sources[f.key] = "override";
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
values[f.key] = f.default;
|
|
837
|
+
sources[f.key] = "default";
|
|
838
|
+
}
|
|
632
839
|
}
|
|
633
|
-
return
|
|
840
|
+
return { values, sources };
|
|
634
841
|
}
|
|
635
|
-
/**
|
|
636
|
-
*
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
let stored = {};
|
|
842
|
+
/** 从 <pluginDir>/storage.json 读 settings 存值,按 schema 并默认值。
|
|
843
|
+
* secret 字段不返回明文:有 secrets 时返回有无(布尔),调用方是浏览器;
|
|
844
|
+
* 插件运行时要真值请用 runtimeSettingsValues。 */
|
|
845
|
+
function readStoredSettings(dir) {
|
|
640
846
|
try {
|
|
641
847
|
const parsed = JSON.parse(readFileSync(join(dir, "storage.json"), "utf8"));
|
|
642
848
|
if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
|
|
643
|
-
|
|
849
|
+
return parsed.settings;
|
|
644
850
|
}
|
|
645
851
|
}
|
|
646
852
|
catch {
|
|
647
853
|
/* 无存储文件 = 全默认 */
|
|
648
854
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
855
|
+
return {};
|
|
856
|
+
}
|
|
857
|
+
function storedSettingsValues(dir, schema, secrets, overlay = {}) {
|
|
858
|
+
// 浏览器侧 secret 只看到有无(布尔),明文永不下发;无 secrets 上下文(如单测)回落 false。
|
|
859
|
+
return resolveSettingsValues(schema, readStoredSettings(dir), overlay, secrets ? { has: (k) => secrets.has(k) } : undefined).values;
|
|
860
|
+
}
|
|
861
|
+
/** 插件运行时视角的设置值:非 secret 与 storedSettingsValues 同口径;secret 返回真值
|
|
862
|
+
* (无则回落默认值/空串)。只给插件服务端代码用,绝不下发浏览器。 */
|
|
863
|
+
function runtimeSettingsValues(dir, schema, secrets, overlay = {}) {
|
|
864
|
+
return resolveSettingsValues(schema, readStoredSettings(dir), overlay, { get: (k) => secrets.get(k) }).values;
|
|
865
|
+
}
|
|
866
|
+
/** 清洗单个非 secret 设置值(P2-9 抽出:保存路径与 overlay 复用同一套口径)。
|
|
867
|
+
* v === undefined → 回落 schema 默认;类型不对/越界/非法候选 → error(调用方丢弃该键)。
|
|
868
|
+
* secret 字段不在这里处理(overlay 直接拒绝,明文永不进加密 store)。 */
|
|
869
|
+
function cleanNonSecretSettingsField(f, v, lang) {
|
|
870
|
+
if (f.type === "number") {
|
|
871
|
+
const n = v === undefined ? Number(f.default ?? 0) : Number(v);
|
|
872
|
+
if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
|
|
873
|
+
return {
|
|
874
|
+
error: pick(lang, `${f.label} 超出范围`, `${f.label} out of range`, "plugins.settings.out.of.range", {
|
|
875
|
+
"f.label": f.label,
|
|
876
|
+
}),
|
|
877
|
+
};
|
|
653
878
|
}
|
|
654
|
-
|
|
879
|
+
return { value: n };
|
|
655
880
|
}
|
|
656
|
-
|
|
881
|
+
if (f.type === "boolean") {
|
|
882
|
+
return { value: v === undefined ? Boolean(f.default) : Boolean(v) };
|
|
883
|
+
}
|
|
884
|
+
if (f.type === "select") {
|
|
885
|
+
const s = v === undefined ? "" : String(v);
|
|
886
|
+
// optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
|
|
887
|
+
// 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
|
|
888
|
+
if (f.optionsFrom) {
|
|
889
|
+
if (s.length > 200) {
|
|
890
|
+
return {
|
|
891
|
+
error: pick(lang, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
|
|
892
|
+
"f.label": f.label,
|
|
893
|
+
}),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
return { value: v === undefined ? (f.default ?? "") : s };
|
|
897
|
+
}
|
|
898
|
+
if (v !== undefined && !f.options?.includes(s))
|
|
899
|
+
return {
|
|
900
|
+
error: pick(lang, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
|
|
901
|
+
"f.label": f.label,
|
|
902
|
+
}),
|
|
903
|
+
};
|
|
904
|
+
return { value: v === undefined ? f.default : s };
|
|
905
|
+
}
|
|
906
|
+
return { value: v === undefined ? (f.default ?? "") : String(v) };
|
|
657
907
|
}
|
|
658
908
|
/** 校验并写回 settings(storage.json 的 settings 键,原子写);返回错误信息或 null。
|
|
659
909
|
* secret 字段写加密 secrets(明文永不落盘、不进 storage.json):空串/缺省 = 不改;
|
|
@@ -665,45 +915,11 @@ lang, secrets) {
|
|
|
665
915
|
const clean = {};
|
|
666
916
|
for (const f of schema) {
|
|
667
917
|
const v = values?.[f.key];
|
|
668
|
-
if (f.type
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
return {
|
|
672
|
-
|
|
673
|
-
"f.label": f.label,
|
|
674
|
-
}),
|
|
675
|
-
clean,
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
clean[f.key] = n;
|
|
679
|
-
}
|
|
680
|
-
else if (f.type === "boolean") {
|
|
681
|
-
clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
|
|
682
|
-
}
|
|
683
|
-
else if (f.type === "select") {
|
|
684
|
-
const s = v === undefined ? "" : String(v);
|
|
685
|
-
// optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
|
|
686
|
-
// 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
|
|
687
|
-
if (f.optionsFrom) {
|
|
688
|
-
if (s.length > 200) {
|
|
689
|
-
return {
|
|
690
|
-
error: pick(l, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
|
|
691
|
-
"f.label": f.label,
|
|
692
|
-
}),
|
|
693
|
-
clean,
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
clean[f.key] = v === undefined ? (f.default ?? "") : s;
|
|
697
|
-
continue;
|
|
698
|
-
}
|
|
699
|
-
if (v !== undefined && !f.options?.includes(s))
|
|
700
|
-
return {
|
|
701
|
-
error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
|
|
702
|
-
"f.label": f.label,
|
|
703
|
-
}),
|
|
704
|
-
clean,
|
|
705
|
-
};
|
|
706
|
-
clean[f.key] = v === undefined ? f.default : s;
|
|
918
|
+
if (f.type !== "secret") {
|
|
919
|
+
const r = cleanNonSecretSettingsField(f, v, l);
|
|
920
|
+
if (r.error)
|
|
921
|
+
return { error: r.error, clean };
|
|
922
|
+
clean[f.key] = r.value;
|
|
707
923
|
}
|
|
708
924
|
else if (f.type === "secret") {
|
|
709
925
|
// 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
|
|
@@ -742,16 +958,20 @@ lang, secrets) {
|
|
|
742
958
|
if (!secretKeys.has(k))
|
|
743
959
|
persist[k] = v;
|
|
744
960
|
const file = join(dir, "storage.json");
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
961
|
+
// 与 PluginStorage.set/delete 同一把按文件路径的 RMW 锁(见 plugin-facilities.ts
|
|
962
|
+
// 的 withFileRmwLock):两个「读-改-写」者串行,谁也不会拿旧快照抹掉对方刚写的键。
|
|
963
|
+
withFileRmwLock(file, () => {
|
|
964
|
+
let existing = {};
|
|
965
|
+
try {
|
|
966
|
+
existing = JSON.parse(readFileSync(file, "utf8"));
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
/* 首次 */
|
|
970
|
+
}
|
|
971
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
972
|
+
writeFileSync(tmp, JSON.stringify({ ...existing, settings: persist }));
|
|
973
|
+
renameSync(tmp, file);
|
|
974
|
+
});
|
|
755
975
|
}
|
|
756
976
|
catch (err) {
|
|
757
977
|
console.error(`[plugins] settings persist failed (${dir}):`, err);
|
|
@@ -1003,6 +1223,17 @@ export class PluginManager {
|
|
|
1003
1223
|
this.senders.add(s);
|
|
1004
1224
|
return () => this.senders.delete(s);
|
|
1005
1225
|
}
|
|
1226
|
+
/** 当前在线(hello 过)的 clientId 去重集合。插件授权弹窗广播后按它做应答
|
|
1227
|
+
* 来源绑定——广播后才连上的端没见过弹窗,不许代答(index.ts 三类授权应答校验)。 */
|
|
1228
|
+
onlineClientIds() {
|
|
1229
|
+
const out = new Set();
|
|
1230
|
+
for (const s of this.senders) {
|
|
1231
|
+
const cid = s.cid();
|
|
1232
|
+
if (cid)
|
|
1233
|
+
out.add(cid);
|
|
1234
|
+
}
|
|
1235
|
+
return [...out];
|
|
1236
|
+
}
|
|
1006
1237
|
/** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。
|
|
1007
1238
|
* 插件代码不可信——同步抛错与返回的 Promise rejection 都必须隔离在
|
|
1008
1239
|
* 这里,绝不能炸主进程。 */
|
|
@@ -1169,6 +1400,11 @@ export class PluginManager {
|
|
|
1169
1400
|
return false;
|
|
1170
1401
|
return !this.domConsentStore.has(pluginId);
|
|
1171
1402
|
}
|
|
1403
|
+
/** plugin_dom_consent 两步握手的预检(index.ts 用):只有声明了 dom 能力的
|
|
1404
|
+
* 插件才允许生成在途 consent 请求——任意 pluginId 进不来,在途表不膨胀。 */
|
|
1405
|
+
isDomPlugin(pluginId) {
|
|
1406
|
+
return this.domWants.get(pluginId) === true;
|
|
1407
|
+
}
|
|
1172
1408
|
/** 特权 DOM 授权/撤销(设置面板 plugin_dom_consent)。
|
|
1173
1409
|
* 返回 { changed }:变了才 epoch+1 重推(浏览器按新 epoch 重拉 bundle,
|
|
1174
1410
|
* 失败缓存随 syncPluginViews 的 epoch 切换清掉);目标不是 wantsDom 插件
|
|
@@ -1229,6 +1465,81 @@ export class PluginManager {
|
|
|
1229
1465
|
}
|
|
1230
1466
|
}
|
|
1231
1467
|
}
|
|
1468
|
+
/** agent-service 调(P1-5):bash/read 执行前的守卫求值。首个 deny/ask 胜出;
|
|
1469
|
+
* 守卫抛错/超时一律按弃权(不阻断)。无守卫时回 allow,调用方可直接放行。 */
|
|
1470
|
+
async evaluateToolPre(req, lang = "en") {
|
|
1471
|
+
const frozen = {
|
|
1472
|
+
toolName: req.toolName,
|
|
1473
|
+
params: freezeParams(req.params),
|
|
1474
|
+
conversationId: req.conversationId,
|
|
1475
|
+
};
|
|
1476
|
+
for (const p of this.loaded.values()) {
|
|
1477
|
+
for (const h of p.preGuards ?? []) {
|
|
1478
|
+
let raw;
|
|
1479
|
+
try {
|
|
1480
|
+
raw = await withGuardTimeout(Promise.resolve().then(() => h(frozen)));
|
|
1481
|
+
}
|
|
1482
|
+
catch (err) {
|
|
1483
|
+
console.error(`[plugin:${p.info.id}] tool-pre guard failed (abstained):`, err);
|
|
1484
|
+
this.pushRuntimeDiag(p.info.id, `tool-pre guard threw, abstained (${req.toolName})`);
|
|
1485
|
+
continue;
|
|
1486
|
+
}
|
|
1487
|
+
if (raw === undefined)
|
|
1488
|
+
continue;
|
|
1489
|
+
const d = normalizePreDecision(raw);
|
|
1490
|
+
if (!isBlockingDecision(d))
|
|
1491
|
+
continue;
|
|
1492
|
+
this.pushRuntimeDiag(p.info.id, `tool-pre ${d.decision} ${req.toolName} (by ${p.info.id})`);
|
|
1493
|
+
return { verdict: d, pluginId: p.info.id };
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return { verdict: { decision: "allow" } };
|
|
1497
|
+
}
|
|
1498
|
+
/** agent-service 调(P1-5):bash/read 执行后的守卫合并。逐个顺序合进结果;
|
|
1499
|
+
* 抛错/超时的那个被跳过(其余继续)。返回合并后的 content(无编辑回 undefined)。 */
|
|
1500
|
+
async evaluateToolPost(req, lang = "en") {
|
|
1501
|
+
let content = Array.isArray(req.result?.content)
|
|
1502
|
+
? [...req.result.content]
|
|
1503
|
+
: undefined;
|
|
1504
|
+
const base = { ...req.result, ...(content ? { content: [...content] } : {}) };
|
|
1505
|
+
let touched = false;
|
|
1506
|
+
const pluginIds = [];
|
|
1507
|
+
const frozenReq = {
|
|
1508
|
+
toolName: req.toolName,
|
|
1509
|
+
params: freezeParams(req.params),
|
|
1510
|
+
result: req.result,
|
|
1511
|
+
conversationId: req.conversationId,
|
|
1512
|
+
};
|
|
1513
|
+
for (const p of this.loaded.values()) {
|
|
1514
|
+
for (const h of p.postGuards ?? []) {
|
|
1515
|
+
let raw;
|
|
1516
|
+
try {
|
|
1517
|
+
raw = await withGuardTimeout(Promise.resolve().then(() => h(frozenReq)));
|
|
1518
|
+
}
|
|
1519
|
+
catch (err) {
|
|
1520
|
+
console.error(`[plugin:${p.info.id}] tool-post guard failed (skipped):`, err);
|
|
1521
|
+
this.pushRuntimeDiag(p.info.id, `tool-post guard threw, skipped (${req.toolName})`);
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
const edit = normalizePostEdit(raw);
|
|
1525
|
+
if (!edit)
|
|
1526
|
+
continue;
|
|
1527
|
+
const merged = applyPostEdit(base, edit, lang);
|
|
1528
|
+
base.content = merged.content;
|
|
1529
|
+
Object.assign(base, merged);
|
|
1530
|
+
touched = true;
|
|
1531
|
+
pluginIds.push(p.info.id);
|
|
1532
|
+
this.pushRuntimeDiag(p.info.id, `tool-post edited ${req.toolName} (by ${p.info.id})`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
if (!touched)
|
|
1536
|
+
return undefined;
|
|
1537
|
+
return { content: base.content, pluginIds };
|
|
1538
|
+
}
|
|
1539
|
+
/** 阻断时给模型看的一句话(pre 守卫命中后由 agent-service 取用)。 */
|
|
1540
|
+
guardDenialText(verdict, pluginId, lang = "en") {
|
|
1541
|
+
return denialText(verdict, pluginId, lang);
|
|
1542
|
+
}
|
|
1232
1543
|
/** index.ts 注入:读取当前打开对话的快照(轨迹类插件经 host.getActiveConversation 调用)。 */
|
|
1233
1544
|
conversationProvider = undefined;
|
|
1234
1545
|
/** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
|
|
@@ -1351,13 +1662,37 @@ export class PluginManager {
|
|
|
1351
1662
|
}
|
|
1352
1663
|
/** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
|
|
1353
1664
|
getActiveConversation() {
|
|
1665
|
+
let snap;
|
|
1354
1666
|
try {
|
|
1355
|
-
|
|
1667
|
+
snap = this.conversationProvider?.() ?? null;
|
|
1356
1668
|
}
|
|
1357
1669
|
catch (err) {
|
|
1358
1670
|
console.error("[plugins] conversationProvider failed:", err);
|
|
1359
1671
|
return null;
|
|
1360
1672
|
}
|
|
1673
|
+
if (!snap)
|
|
1674
|
+
return null;
|
|
1675
|
+
// 交给插件前做防御性拷贝:provider 回的是快照管线的**活引用** —— messages
|
|
1676
|
+
// 数组与元素对象都被 60ms 推送管线缓存复用(lastMessagesArray / uiMessageCache),
|
|
1677
|
+
// 插件原地改一个字段/挪一个元素,污染的就是推给真实客户端的快照。浅拷数组 +
|
|
1678
|
+
// 元素对象 structuredClone;元素是纯 JSON 结构(details 已过 JSON.stringify 闸),
|
|
1679
|
+
// structuredClone 不会失败,JSON 往返只是万一携带不可克隆值时的兜底。
|
|
1680
|
+
return {
|
|
1681
|
+
...snap,
|
|
1682
|
+
messages: snap.messages.map((m) => {
|
|
1683
|
+
try {
|
|
1684
|
+
return structuredClone(m);
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
try {
|
|
1688
|
+
return JSON.parse(JSON.stringify(m));
|
|
1689
|
+
}
|
|
1690
|
+
catch {
|
|
1691
|
+
return { ...m }; // 连 JSON 往返都失败(循环引用):至少不共享顶层对象
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}),
|
|
1695
|
+
};
|
|
1361
1696
|
}
|
|
1362
1697
|
/** agent-service 调:当前打开对话变了(切历史会话/切 running 对话/新对话)——
|
|
1363
1698
|
* 轨迹类插件靠它重拉时间线(异常隔离)。 */
|
|
@@ -1435,13 +1770,23 @@ export class PluginManager {
|
|
|
1435
1770
|
out.push(...table.values());
|
|
1436
1771
|
return out;
|
|
1437
1772
|
}
|
|
1438
|
-
/** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
|
|
1773
|
+
/** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
|
|
1774
|
+
* 跨插件重名同样拒绝(与 registerCommand 同口径):工具进会话时按名字合入
|
|
1775
|
+
* (syncPluginToolsIntoSession 的 byName 覆盖),重名意味着后注册者静默抢注
|
|
1776
|
+
* 前者的工具,必须在这里挡下。 */
|
|
1439
1777
|
registerAgentTool(pluginId, tool) {
|
|
1440
1778
|
if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
|
|
1441
1779
|
console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
|
|
1442
1780
|
this.pushRuntimeDiag(pluginId, "registerAgentTool: missing name/description/execute, ignored");
|
|
1443
1781
|
return () => { };
|
|
1444
1782
|
}
|
|
1783
|
+
for (const [pid, other] of this.agentTools) {
|
|
1784
|
+
if (pid !== pluginId && other.has(tool.name)) {
|
|
1785
|
+
console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 已被插件 ${pid} 注册,忽略重复`);
|
|
1786
|
+
this.pushRuntimeDiag(pluginId, `agent tool "${tool.name}": already registered by plugin ${pid}, ignored`);
|
|
1787
|
+
return () => { };
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1445
1790
|
let table = this.agentTools.get(pluginId);
|
|
1446
1791
|
if (!table)
|
|
1447
1792
|
this.agentTools.set(pluginId, (table = new Map()));
|
|
@@ -1618,13 +1963,61 @@ export class PluginManager {
|
|
|
1618
1963
|
async list(lang) {
|
|
1619
1964
|
return this.scan(lang);
|
|
1620
1965
|
}
|
|
1966
|
+
/** 机器可读的注册面目录(P2-7):slot 别名/kind/例子 + 工具目录 +
|
|
1967
|
+
* 宿主方法表 + 当前占用者(现算)。只读装配,无副作用。 */
|
|
1968
|
+
getApiCatalog() {
|
|
1969
|
+
const counts = new Map();
|
|
1970
|
+
const add = (pluginId, slot) => {
|
|
1971
|
+
let m = counts.get(slot);
|
|
1972
|
+
if (!m)
|
|
1973
|
+
counts.set(slot, (m = new Map()));
|
|
1974
|
+
m.set(pluginId, (m.get(pluginId) ?? 0) + 1);
|
|
1975
|
+
};
|
|
1976
|
+
for (const [pid, base] of this.uiBase)
|
|
1977
|
+
for (const it of base.items ?? [])
|
|
1978
|
+
add(pid, it.slot);
|
|
1979
|
+
for (const [pid, rt] of this.uiRuntime)
|
|
1980
|
+
for (const it of rt.items.values())
|
|
1981
|
+
add(pid, it.slot);
|
|
1982
|
+
return buildPluginApiCatalog({
|
|
1983
|
+
slots: [...UI_SLOTS],
|
|
1984
|
+
aliases: { ...UI_SLOT_ALIASES },
|
|
1985
|
+
kinds: [...UI_KINDS],
|
|
1986
|
+
agentTools: AGENT_TOOL_CATALOG.map((t) => ({
|
|
1987
|
+
name: t.name,
|
|
1988
|
+
group: t.group,
|
|
1989
|
+
defaultOn: t.defaultOn,
|
|
1990
|
+
dshVisible: t.dshVisible,
|
|
1991
|
+
})),
|
|
1992
|
+
occupantsOf: (slot) => {
|
|
1993
|
+
const m = counts.get(slot);
|
|
1994
|
+
if (!m)
|
|
1995
|
+
return [];
|
|
1996
|
+
return [...m].map(([pluginId, items]) => ({ pluginId, items }));
|
|
1997
|
+
},
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
1621
2000
|
/**
|
|
1622
2001
|
* attach 时调用:重扫目录 + 激活尚未加载的新插件。
|
|
1623
2002
|
* 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
|
|
1624
2003
|
*/
|
|
1625
2004
|
async ensureLoaded(lang) {
|
|
2005
|
+
const l = lang?.() ?? "en";
|
|
1626
2006
|
const found = await this.scan(lang);
|
|
1627
|
-
|
|
2007
|
+
const byId = new Map(found.map((f) => [f.id, f]));
|
|
2008
|
+
const scannedIds = new Set(byId.keys());
|
|
2009
|
+
// P2-8:依赖拓扑排序激活(被依赖者先行;决定启动时机的是依赖,不是目录顺序)。
|
|
2010
|
+
// 边只连本次扫描里存在的对端;缺失的对端由 activate 点名拒绝。环上节点直接拒。
|
|
2011
|
+
const { order, cyclic } = sortByRequires(found.map((f) => f.id), (id) => (byId.get(id)?.requires?.plugins ?? []).filter((d) => scannedIds.has(d)));
|
|
2012
|
+
for (const cycId of cyclic) {
|
|
2013
|
+
const info = byId.get(cycId);
|
|
2014
|
+
if (this.loaded.has(info.id) || this.attempted.has(info.id))
|
|
2015
|
+
continue;
|
|
2016
|
+
const msg = pick(l, `插件 requires 存在循环依赖(${cycId})—— 请先解环再激活`, `Plugin has cyclic requires (${cycId}) — break the cycle first`, "plugins.requires.cycle", { id: cycId });
|
|
2017
|
+
this.setFrozenRefusal(info, msg, `cyclic requires (${cycId})`);
|
|
2018
|
+
}
|
|
2019
|
+
for (const id of order) {
|
|
2020
|
+
const info = byId.get(id);
|
|
1628
2021
|
if (this.loaded.has(info.id) || this.attempted.has(info.id))
|
|
1629
2022
|
continue;
|
|
1630
2023
|
if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
|
|
@@ -1638,6 +2031,8 @@ export class PluginManager {
|
|
|
1638
2031
|
this.deactivateEntry(id, p);
|
|
1639
2032
|
}
|
|
1640
2033
|
}
|
|
2034
|
+
// P2-8 级联:提供方刚坏(被删/失败/被拒)→ 仍在跑的消费方一并反激活 + 教学占位。
|
|
2035
|
+
this.cascadeRequiresRefusals(found, lang);
|
|
1641
2036
|
return found.map((f) => {
|
|
1642
2037
|
const base = this.loaded.get(f.id)?.info ?? f;
|
|
1643
2038
|
// 运行相位(设置面板清单用):宿主持有实例即 active(含激活失败的占位行;
|
|
@@ -1650,32 +2045,23 @@ export class PluginManager {
|
|
|
1650
2045
|
return rest;
|
|
1651
2046
|
});
|
|
1652
2047
|
}
|
|
1653
|
-
/**
|
|
1654
|
-
*
|
|
2048
|
+
/** 反激活清理:把该插件名下全部订阅/注册一次收完。
|
|
2049
|
+
*
|
|
2050
|
+
* 两段:① 副作用栈逆序回卷(插件注册的全部可逆副作用都在里面 —— 工具/命令/路由/
|
|
2051
|
+
* 代理/watch/定时/后台任务/事件订阅/UI 条目…;跑不干净的记一条诊断);
|
|
2052
|
+
* ② 兜底回收全局表里按 pluginId 索引的残留(老宿主激活的实例没有栈、或
|
|
2053
|
+
* 插件绕过 host 自己往表里塞过东西:代理前缀按 id 过滤清一遍,幂等)。 */
|
|
1655
2054
|
releaseEntry(p) {
|
|
1656
|
-
//
|
|
2055
|
+
// ① 逆序回卷。失败项已在栈内打过诊断,这里不再重复。
|
|
2056
|
+
const failed = p.effects?.release() ?? [];
|
|
2057
|
+
if (failed.length > 0)
|
|
2058
|
+
console.warn(`[plugin:${p.info.id}] ${failed.length} effect(s) failed to clean up: ${failed.join(", ")}`);
|
|
2059
|
+
// ② 兜底:代理前缀随反激活一起回收(全局表按 pluginId 过滤;
|
|
1657
2060
|
// Map 迭代中删除是良定义的:删过的条目不会再被访问到)。
|
|
1658
2061
|
for (const [prefix, hit] of this.proxyRoutes) {
|
|
1659
2062
|
if (hit.pluginId === p.info.id)
|
|
1660
2063
|
this.proxyRoutes.delete(prefix);
|
|
1661
2064
|
}
|
|
1662
|
-
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
1663
|
-
for (const off of [
|
|
1664
|
-
...(p.agentToolUnsubscribers ?? []),
|
|
1665
|
-
...(p.commandUnsubscribers ?? []),
|
|
1666
|
-
...(p.watchUnsubscribers ?? []),
|
|
1667
|
-
...(p.scheduleUnsubscribers ?? []),
|
|
1668
|
-
...(p.busUnsubscribers ?? []),
|
|
1669
|
-
...(p.statsUnsubscribers ?? []),
|
|
1670
|
-
...(p.streamingUnsubscribers ?? []),
|
|
1671
|
-
]) {
|
|
1672
|
-
try {
|
|
1673
|
-
off();
|
|
1674
|
-
}
|
|
1675
|
-
catch {
|
|
1676
|
-
/* already gone */
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
2065
|
}
|
|
1680
2066
|
/** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
|
|
1681
2067
|
*
|
|
@@ -1709,17 +2095,26 @@ export class PluginManager {
|
|
|
1709
2095
|
this.epochCounter += 1;
|
|
1710
2096
|
console.log(`[plugin:${id}] removed`);
|
|
1711
2097
|
}
|
|
1712
|
-
/**
|
|
2098
|
+
/** 关机 / reload 时反激活全部插件(dispose 后 loaded 为空,后续 releaseEntry 是 no-op)。 */
|
|
1713
2099
|
dispose() {
|
|
2100
|
+
const pluginIds = new Set();
|
|
1714
2101
|
for (const [id, p] of this.loaded) {
|
|
2102
|
+
pluginIds.add(id);
|
|
1715
2103
|
try {
|
|
1716
2104
|
p.deactivate?.();
|
|
1717
2105
|
}
|
|
1718
2106
|
catch (err) {
|
|
1719
2107
|
console.error(`[plugin:${id}] deactivate failed:`, err);
|
|
1720
2108
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
2109
|
+
}
|
|
2110
|
+
// ① 先回卷全部 effect 栈(含 bgTask 停表、工具/命令/路由/watch/UI 条目注销),
|
|
2111
|
+
// 并把 loaded 清掉 —— 之后逐个走 releaseEntry 的兜底回收。
|
|
2112
|
+
for (const [id, p] of this.loaded) {
|
|
2113
|
+
p.effects?.release();
|
|
2114
|
+
this.loaded.delete(id);
|
|
2115
|
+
}
|
|
2116
|
+
// ② 兜底:停掉任何没经 effect 栈登记的常驻后台任务,不留孤儿计时器。
|
|
2117
|
+
for (const id of pluginIds) {
|
|
1723
2118
|
for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
|
|
1724
2119
|
try {
|
|
1725
2120
|
t.stop?.();
|
|
@@ -1729,6 +2124,11 @@ export class PluginManager {
|
|
|
1729
2124
|
}
|
|
1730
2125
|
}
|
|
1731
2126
|
}
|
|
2127
|
+
// ③ 兜底:清掉插件名下的代理前缀(该插件名下但未经栈登记的残留)。
|
|
2128
|
+
for (const prefix of [...this.proxyRoutes.keys()]) {
|
|
2129
|
+
if (pluginIds.has(this.proxyRoutes.get(prefix).pluginId))
|
|
2130
|
+
this.proxyRoutes.delete(prefix);
|
|
2131
|
+
}
|
|
1732
2132
|
this.pluginBgTasks.clear();
|
|
1733
2133
|
this.loaded.clear();
|
|
1734
2134
|
this.messageHandlers.clear();
|
|
@@ -1762,6 +2162,43 @@ export class PluginManager {
|
|
|
1762
2162
|
this.domWants.set(name, wantsDom);
|
|
1763
2163
|
// 本轮 manifest 解析的诊断(重算覆盖;运行时诊断另存在 runtimeDiags)。
|
|
1764
2164
|
const uiDiags = [];
|
|
2165
|
+
// P1-6:manifest 本体先校验,失败即拒(坏配置不进运行时)。
|
|
2166
|
+
const mv = validatePluginManifest(m, name);
|
|
2167
|
+
for (const w of mv.warnings)
|
|
2168
|
+
uiDiags.push(formatManifestIssue(w));
|
|
2169
|
+
if (mv.errors.length > 0) {
|
|
2170
|
+
for (const e of mv.errors)
|
|
2171
|
+
uiDiags.push(formatManifestIssue(e));
|
|
2172
|
+
const first = mv.errors[0];
|
|
2173
|
+
const msg = pick(l, `manifest 校验失败:${first.message}(${name}/)—— 修复后点“重新扫描”`, `manifest validation failed: ${first.messageEn} (${name}/) — fix it, then hit Rescan`, "plugins.manifest.invalid", { name, path: first.path });
|
|
2174
|
+
console.error(`[plugin:${name}] ${msg}`);
|
|
2175
|
+
this.manifestDiags.set(name, uiDiags);
|
|
2176
|
+
const refused = [...uiDiags, ...(this.runtimeDiags.get(name) ?? [])].slice(0, 100);
|
|
2177
|
+
out.push({
|
|
2178
|
+
id: name,
|
|
2179
|
+
name: typeof m.name === "string" && m.name ? m.name : name,
|
|
2180
|
+
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
2181
|
+
error: msg,
|
|
2182
|
+
view: false,
|
|
2183
|
+
...(refused.length ? { diagnostics: refused } : {}),
|
|
2184
|
+
});
|
|
2185
|
+
const lpRefused = this.loaded.get(name);
|
|
2186
|
+
if (lpRefused) {
|
|
2187
|
+
if (refused.length)
|
|
2188
|
+
lpRefused.info.diagnostics = [...refused];
|
|
2189
|
+
else
|
|
2190
|
+
delete lpRefused.info.diagnostics;
|
|
2191
|
+
}
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
// P2-9:声明式设置的三层合并(schema 默认 < 用户 overlay < 面板保存值)。
|
|
2195
|
+
// overlay 先读先洗,警告进诊断(英文短句,随清单下发);secret 永不来自 overlay。
|
|
2196
|
+
const settingsSchemaForScan = parseSettingsSchema(m.settings);
|
|
2197
|
+
const overlayFileForScan = readSettingsOverlayFile(this.dataDir, name);
|
|
2198
|
+
const overlayForScan = cleanOverlaySettings(settingsSchemaForScan, overlayFileForScan.raw, "en");
|
|
2199
|
+
for (const w of [...overlayFileForScan.warnings, ...overlayForScan.warnings])
|
|
2200
|
+
uiDiags.push(`override: ${w}`);
|
|
2201
|
+
const resolvedSettingsForScan = resolveSettingsValues(settingsSchemaForScan, readStoredSettings(dir), overlayForScan.values, { has: (k) => new PluginSecrets(this.dataDir, dir).has(k) });
|
|
1765
2202
|
out.push({
|
|
1766
2203
|
id: name,
|
|
1767
2204
|
name: typeof m.name === "string" && m.name ? m.name : name,
|
|
@@ -1792,6 +2229,9 @@ export class PluginManager {
|
|
|
1792
2229
|
peerPlugins: Array.isArray(m.peerPlugins)
|
|
1793
2230
|
? m.peerPlugins.filter((x) => typeof x === "string" && ID_RE.test(x)).slice(0, 16)
|
|
1794
2231
|
: undefined,
|
|
2232
|
+
// 硬依赖声明(manifest "requires",P2-8):形状坏的在 P1-6 校验层已拒,
|
|
2233
|
+
// 这里只做宽容解析供展示 + activate 语义判定(缺失/失败/环/版本/能力即拒)。
|
|
2234
|
+
requires: parseRequires(m.requires),
|
|
1795
2235
|
// 特权 DOM:permissions 含 dom 族 → bundle 默认 403,需用户逐个授权。
|
|
1796
2236
|
// wantsDom 缓进 domWants(静态门禁查它,不必每次 readdir);error 在未授权时
|
|
1797
2237
|
// 直接写明原因(tab 置灰 + 设置行提示),授权后下次 scan 自动清除。
|
|
@@ -1807,8 +2247,9 @@ export class PluginManager {
|
|
|
1807
2247
|
}
|
|
1808
2248
|
: {}),
|
|
1809
2249
|
// 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
|
|
1810
|
-
settingsSchema:
|
|
1811
|
-
settingsValues:
|
|
2250
|
+
settingsSchema: settingsSchemaForScan,
|
|
2251
|
+
settingsValues: resolvedSettingsForScan.values,
|
|
2252
|
+
settingsSources: resolvedSettingsForScan.sources,
|
|
1812
2253
|
// 可渲染的 fenced-code 语言(manifest "renderers")——前端据此按需加载
|
|
1813
2254
|
renderers: Array.isArray(m.renderers)
|
|
1814
2255
|
? m.renderers.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
@@ -1823,6 +2264,39 @@ export class PluginManager {
|
|
|
1823
2264
|
composerProviders: Array.isArray(m.composerProviders)
|
|
1824
2265
|
? m.composerProviders.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
|
|
1825
2266
|
: undefined,
|
|
2267
|
+
// 文件单击查看器声明:只下发扩展名/处理器 id/优先级,实际 DOM
|
|
2268
|
+
// 渲染仍由客户端 bundle 提供,避免 manifest 携带可执行内容。
|
|
2269
|
+
fileHandlers: (() => {
|
|
2270
|
+
if (!Array.isArray(m.fileHandlers))
|
|
2271
|
+
return undefined;
|
|
2272
|
+
const rows = m.fileHandlers
|
|
2273
|
+
.filter((x) => Boolean(x) && typeof x === "object" && !Array.isArray(x))
|
|
2274
|
+
.slice(0, 32)
|
|
2275
|
+
.map((x) => {
|
|
2276
|
+
const extensions = Array.isArray(x.extensions)
|
|
2277
|
+
? x.extensions
|
|
2278
|
+
.filter((e) => typeof e === "string" && /^\.[A-Za-z0-9][A-Za-z0-9._-]*$/.test(e.trim()))
|
|
2279
|
+
.map((e) => e.trim().toLowerCase())
|
|
2280
|
+
.slice(0, 32)
|
|
2281
|
+
: [];
|
|
2282
|
+
if (!extensions.length)
|
|
2283
|
+
return null;
|
|
2284
|
+
const id = typeof x.id === "string" && /^[A-Za-z0-9_-]+$/.test(x.id) ? x.id : "default";
|
|
2285
|
+
return {
|
|
2286
|
+
id,
|
|
2287
|
+
extensions: [...new Set(extensions)],
|
|
2288
|
+
priority: Number.isFinite(Number(x.priority))
|
|
2289
|
+
? Math.max(-1000, Math.min(1000, Number(x.priority)))
|
|
2290
|
+
: 0,
|
|
2291
|
+
...(typeof x.label === "string" && x.label.trim() ? { label: x.label.trim().slice(0, 120) } : {}),
|
|
2292
|
+
...(typeof x.labelEn === "string" && x.labelEn.trim()
|
|
2293
|
+
? { labelEn: x.labelEn.trim().slice(0, 120) }
|
|
2294
|
+
: {}),
|
|
2295
|
+
};
|
|
2296
|
+
})
|
|
2297
|
+
.filter((x) => x !== null);
|
|
2298
|
+
return rows.length ? rows : undefined;
|
|
2299
|
+
})(),
|
|
1826
2300
|
// 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
|
|
1827
2301
|
view: typeof m.view === "boolean" ? m.view : true,
|
|
1828
2302
|
// 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
|
|
@@ -1910,22 +2384,120 @@ export class PluginManager {
|
|
|
1910
2384
|
}
|
|
1911
2385
|
return out;
|
|
1912
2386
|
}
|
|
2387
|
+
/** 非 activate 上下文的拒绝占位(P2-8 环/级联):空集合 + error + 诊断,
|
|
2388
|
+
* 与各门控的占位行同形(ensureLoaded 照例跳过,reload 重算刷新)。 */
|
|
2389
|
+
setFrozenRefusal(info, msg, diagEn) {
|
|
2390
|
+
console.error(`[plugin:${info.id}] ${msg}`);
|
|
2391
|
+
this.pushRuntimeDiag(info.id, diagEn);
|
|
2392
|
+
this.loaded.set(info.id, {
|
|
2393
|
+
info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
|
|
2394
|
+
toolHandlers: new Set(),
|
|
2395
|
+
runHandlers: new Set(),
|
|
2396
|
+
convChangeHandlers: new Set(),
|
|
2397
|
+
attachHandlers: new Set(),
|
|
2398
|
+
cwdHandlers: new Set(),
|
|
2399
|
+
httpRoutes: new Map(),
|
|
2400
|
+
settingsHandlers: new Set(),
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
/** 级联(P2-8):提供方刚坏掉(被删/失败/被拒)→ 仍在跑的消费方一并反激活 +
|
|
2404
|
+
* 留教学占位。循环直到一轮无变化(链式依赖一轮收敛),返回受影响的 id。
|
|
2405
|
+
* hostApi/families 运行时不变,这里只查 plugins 对端(目录在 + 后端 peer 激活成功)。 */
|
|
2406
|
+
cascadeRequiresRefusals(found, lang) {
|
|
2407
|
+
const l = lang?.() ?? "en";
|
|
2408
|
+
const byId = new Map(found.map((f) => [f.id, f]));
|
|
2409
|
+
const hit = [];
|
|
2410
|
+
for (;;) {
|
|
2411
|
+
let changed = false;
|
|
2412
|
+
for (const [id, p] of [...this.loaded]) {
|
|
2413
|
+
if (p.info.error)
|
|
2414
|
+
continue;
|
|
2415
|
+
const req = byId.get(id)?.requires ?? p.info.requires;
|
|
2416
|
+
if (!req?.plugins?.length)
|
|
2417
|
+
continue;
|
|
2418
|
+
for (const dep of req.plugins) {
|
|
2419
|
+
let why = "";
|
|
2420
|
+
let whyEn = "";
|
|
2421
|
+
if (dep === id) {
|
|
2422
|
+
why = `插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`;
|
|
2423
|
+
whyEn = `Plugin requires itself (${dep}) — remove the self-dependency`;
|
|
2424
|
+
}
|
|
2425
|
+
else if (!byId.has(dep)) {
|
|
2426
|
+
why = `插件 requires 对等插件「${dep}」但它已不在(被删/被拒)—— 装回来或去掉这条依赖`;
|
|
2427
|
+
whyEn = `Plugin requires peer "${dep}" which is gone (removed or refused) — reinstall it or drop the dependency`;
|
|
2428
|
+
}
|
|
2429
|
+
else if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
|
|
2430
|
+
const peer = this.loaded.get(dep);
|
|
2431
|
+
if (!peer || peer.info.error) {
|
|
2432
|
+
why = `插件 requires 对等插件「${dep}」但它已失败/停止:${peer?.info.error ?? "未激活"}—— 修好它或去掉这条依赖`;
|
|
2433
|
+
whyEn = `Plugin requires peer "${dep}" which failed/stopped: ${peer?.info.error ?? "inactive"} — fix it or drop the dependency`;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
if (!why)
|
|
2437
|
+
continue;
|
|
2438
|
+
const msg = pick(l, why, whyEn, "plugins.requires.cascade", { dep });
|
|
2439
|
+
this.deactivateEntry(id, p);
|
|
2440
|
+
this.setFrozenRefusal(byId.get(id) ?? p.info, msg, whyEn);
|
|
2441
|
+
hit.push(id);
|
|
2442
|
+
changed = true;
|
|
2443
|
+
break;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
if (!changed)
|
|
2447
|
+
break;
|
|
2448
|
+
}
|
|
2449
|
+
return hit;
|
|
2450
|
+
}
|
|
1913
2451
|
async activate(info, lang) {
|
|
1914
2452
|
const l = lang?.() ?? "en";
|
|
1915
2453
|
this.attempted.add(info.id);
|
|
1916
2454
|
const dir = join(this.pluginsDir, info.id);
|
|
2455
|
+
// P1-6:manifest 本体失败即拒(scan 缓存可能过期,这里重读重判,半途注册的副作用不留)。
|
|
2456
|
+
try {
|
|
2457
|
+
const rawManifest = readFileSync(join(dir, "manifest.json"), "utf8");
|
|
2458
|
+
const parsedManifest = JSON.parse(rawManifest);
|
|
2459
|
+
const mvActivate = validatePluginManifest(parsedManifest, info.id);
|
|
2460
|
+
if (mvActivate.errors.length > 0) {
|
|
2461
|
+
const first = mvActivate.errors[0];
|
|
2462
|
+
const msg = pick(l, `manifest 校验失败:${first.message} —— 修复后点“重新扫描”`, `manifest validation failed: ${first.messageEn} — fix it, then hit Rescan`, "plugins.manifest.invalid", { name: info.id, path: first.path });
|
|
2463
|
+
console.error(`[plugin:${info.id}] ${msg}`);
|
|
2464
|
+
for (const e of mvActivate.errors)
|
|
2465
|
+
this.pushRuntimeDiag(info.id, formatManifestIssue(e));
|
|
2466
|
+
for (const w of mvActivate.warnings)
|
|
2467
|
+
this.pushRuntimeDiag(info.id, formatManifestIssue(w));
|
|
2468
|
+
this.loaded.set(info.id, {
|
|
2469
|
+
info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
|
|
2470
|
+
toolHandlers: new Set(),
|
|
2471
|
+
runHandlers: new Set(),
|
|
2472
|
+
convChangeHandlers: new Set(),
|
|
2473
|
+
attachHandlers: new Set(),
|
|
2474
|
+
cwdHandlers: new Set(),
|
|
2475
|
+
httpRoutes: new Map(),
|
|
2476
|
+
settingsHandlers: new Set(),
|
|
2477
|
+
});
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
catch {
|
|
2482
|
+
// 读不到/解析失败 → 走原有的 broken 占位逻辑(scan 里已产出,不在这里重复拒绝)。
|
|
2483
|
+
}
|
|
1917
2484
|
const handlers = new Set();
|
|
1918
2485
|
this.messageHandlers.set(info.id, handlers);
|
|
1919
2486
|
const toolHandlers = new Set();
|
|
2487
|
+
const preGuards = new Set();
|
|
2488
|
+
const postGuards = new Set();
|
|
1920
2489
|
const runHandlers = new Set();
|
|
1921
2490
|
const convChangeHandlers = new Set();
|
|
1922
2491
|
const attachHandlers = new Set();
|
|
1923
2492
|
const cwdHandlers = new Set();
|
|
1924
2493
|
const httpRoutes = new Map();
|
|
1925
|
-
const unregisterTools = [];
|
|
1926
|
-
const unregisterCommands = [];
|
|
1927
2494
|
const bgTaskTable = new Map();
|
|
1928
2495
|
const settingsHandlers = new Set();
|
|
2496
|
+
// 可逆副作用栈:本插件的全部注册面(工具/命令/路由/代理/watch/定时/后台任务/
|
|
2497
|
+
// UI 条目/事件订阅…)都经 effects.add 登记,反激活时**逆序**回卷 —— 插件忘写
|
|
2498
|
+
// 注销也不会留下孤儿订阅/定时器/路由(热重载后事件双触发、定时器叠加、watcher
|
|
2499
|
+
// 堆积都是这个漏法的症状)。cleanup 抛错只记诊断,不阻断回卷。
|
|
2500
|
+
const effects = new PluginEffectStack(info.id, (m) => this.pushRuntimeDiag(info.id, m));
|
|
1929
2501
|
// 宿主 API 版本协商:插件要的比宿主新 → 明确拒绝(而不是让它在运行期
|
|
1930
2502
|
// 撞 undefined 接口莫名其妙地坏)。与激活失败同一处理:error 字段 + 置灰。
|
|
1931
2503
|
let apiVersion = 1;
|
|
@@ -1956,6 +2528,76 @@ export class PluginManager {
|
|
|
1956
2528
|
const permsDeclared = (info.permissions ?? []).slice();
|
|
1957
2529
|
const strict = permsDeclared.length > 0 || apiVersion >= 2;
|
|
1958
2530
|
const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
|
|
2531
|
+
// 硬依赖声明(manifest "requires",P2-8):任一条不满足即拒绝激活 + 教学式错误
|
|
2532
|
+
// (peerPlugins 仍是缺失只警告的软依赖)。形状坏的在 P1-6 校验层已提前拒,
|
|
2533
|
+
// 这里只做语义判定:hostApi 下限、能力族已知+已声明、对等插件已安装且激活成功。
|
|
2534
|
+
const req = info.requires;
|
|
2535
|
+
if (req) {
|
|
2536
|
+
const refuseRequires = (zh, en, key, vars) => {
|
|
2537
|
+
const msg = pick(l, zh, en, key, vars);
|
|
2538
|
+
console.error(`[plugin:${info.id}] ${msg}`);
|
|
2539
|
+
this.pushRuntimeDiag(info.id, en);
|
|
2540
|
+
this.loaded.set(info.id, {
|
|
2541
|
+
info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
|
|
2542
|
+
toolHandlers,
|
|
2543
|
+
runHandlers,
|
|
2544
|
+
convChangeHandlers,
|
|
2545
|
+
attachHandlers,
|
|
2546
|
+
cwdHandlers,
|
|
2547
|
+
httpRoutes,
|
|
2548
|
+
settingsHandlers: new Set(),
|
|
2549
|
+
});
|
|
2550
|
+
return true;
|
|
2551
|
+
};
|
|
2552
|
+
if (req.hostApi !== undefined && req.hostApi > PLUGIN_API_VERSION) {
|
|
2553
|
+
refuseRequires(`插件要求宿主 API v${req.hostApi}+,当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`, `Plugin requires host API v${req.hostApi}+ but the host is v${PLUGIN_API_VERSION} — please upgrade pi-web-ui`, "plugins.requires.hostapi", { hostApi: req.hostApi, PLUGIN_API_VERSION });
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
let refused = false;
|
|
2557
|
+
for (const f of req.families ?? []) {
|
|
2558
|
+
if (!isKnownPermission(f)) {
|
|
2559
|
+
refuseRequires(`插件 requires 声明了未知能力族「${f}」(拼写?或需要更新 pi-web-ui)—— 请修正 manifest.requires.families`, `Plugin requires unknown family "${f}" (typo? or needs newer pi-web-ui) — fix manifest.requires.families`, "plugins.requires.family.unknown", { family: f });
|
|
2560
|
+
refused = true;
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
const fam = f.split(":")[0];
|
|
2564
|
+
if (!permFamilies.has(fam)) {
|
|
2565
|
+
refuseRequires(`插件 requires 需要「${fam}」族,但 permissions 里没声明 —— 加上它,否则运行时必被门控拒绝`, `Plugin requires family "${fam}" but does not declare it in permissions — add it, otherwise every call will be denied`, "plugins.requires.family.undeclared", { family: fam });
|
|
2566
|
+
refused = true;
|
|
2567
|
+
break;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
if (refused)
|
|
2571
|
+
return;
|
|
2572
|
+
for (const dep of req.plugins ?? []) {
|
|
2573
|
+
if (dep === info.id) {
|
|
2574
|
+
refuseRequires(`插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`, `Plugin requires itself (${dep}) — remove the self-dependency`, "plugins.requires.self", { dep });
|
|
2575
|
+
refused = true;
|
|
2576
|
+
break;
|
|
2577
|
+
}
|
|
2578
|
+
if (!existsSync(join(this.pluginsDir, dep, "manifest.json"))) {
|
|
2579
|
+
refuseRequires(`插件 requires 对等插件「${dep}」但它没安装(plugins/${dep}/ 缺失)—— 先装上它`, `Plugin requires peer "${dep}" which is not installed (plugins/${dep}/ missing) — install it first`, "plugins.requires.peer.missing", { dep });
|
|
2580
|
+
refused = true;
|
|
2581
|
+
break;
|
|
2582
|
+
}
|
|
2583
|
+
if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
|
|
2584
|
+
const peer = this.loaded.get(dep);
|
|
2585
|
+
if (!peer) {
|
|
2586
|
+
refuseRequires(`插件 requires 对等插件「${dep}」但它尚未激活 —— 请先解决它的问题`, `Plugin requires peer "${dep}" which is not active yet — fix it first`, "plugins.requires.peer.inactive", { dep });
|
|
2587
|
+
refused = true;
|
|
2588
|
+
break;
|
|
2589
|
+
}
|
|
2590
|
+
if (peer.info.error) {
|
|
2591
|
+
refuseRequires(`插件 requires 对等插件「${dep}」但它激活失败:${peer.info.error}`, `Plugin requires peer "${dep}" which failed to activate: ${peer.info.error}`, "plugins.requires.peer.failed", { dep });
|
|
2592
|
+
refused = true;
|
|
2593
|
+
break;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
// 纯前端对等端(无 index.mjs):目录存在即满足,它没有激活态。
|
|
2597
|
+
}
|
|
2598
|
+
if (refused)
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
1959
2601
|
// 引擎约束(manifest engines["pi-web-ui"]):不满足即拒绝激活(与 apiVersion 超前同级处理)。
|
|
1960
2602
|
// 解析失败 → 警告放行不阻断;对等依赖缺失 → 只警告不断活。
|
|
1961
2603
|
const enginesReq = info.engines?.["pi-web-ui"];
|
|
@@ -1989,12 +2631,7 @@ export class PluginManager {
|
|
|
1989
2631
|
this.pushRuntimeDiag(info.id, `peer plugin missing: ${peer} (warn only, activation continues)`);
|
|
1990
2632
|
}
|
|
1991
2633
|
}
|
|
1992
|
-
// 新增订阅的取消函数(反激活时经
|
|
1993
|
-
const watchSubs = [];
|
|
1994
|
-
const scheduleSubs = [];
|
|
1995
|
-
const busSubs = [];
|
|
1996
|
-
const statsSubs = [];
|
|
1997
|
-
const streamingSubs = [];
|
|
2634
|
+
// 新增订阅的取消函数(反激活时经 effects 栈统一逆序释放)。
|
|
1998
2635
|
// 出站网络白名单(scan 解析的 manifest netAllowlist 快照)。
|
|
1999
2636
|
const netAllow = info.netAllowlist ?? [];
|
|
2000
2637
|
// 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
|
|
@@ -2013,6 +2650,23 @@ export class PluginManager {
|
|
|
2013
2650
|
return abs;
|
|
2014
2651
|
throw new Error(`目录未授权:先 await host.fs.requestAccess(dir)(${abs})`);
|
|
2015
2652
|
};
|
|
2653
|
+
/** 写类操作(write/remove)的 realpath 复核:allowAbs 是纯字符串判定,
|
|
2654
|
+
* 授权目录里的符号链接/junction 能把写入/递归删除引到授权范围之外。
|
|
2655
|
+
* 取目标最近已存在祖先的 realpath,要求它仍落在(工作区或该插件任一
|
|
2656
|
+
* 已授权目录)的 realpath 内。读/list/stat 保持字符串校验(高频路径,
|
|
2657
|
+
* 且读不存在「把内容写到别处」的风险)。 */
|
|
2658
|
+
const assertRealInsideGrant = async (abs) => {
|
|
2659
|
+
const targetReal = realPathOfNearest(abs);
|
|
2660
|
+
if (!targetReal)
|
|
2661
|
+
throw new Error(`无法解析真实路径:${abs}`);
|
|
2662
|
+
const granted = self.grants.list().find((g) => g.pluginId === info.id)?.paths ?? [];
|
|
2663
|
+
for (const r of [self.cwdValue, ...granted]) {
|
|
2664
|
+
const rootReal = realPathOfNearest(resolve(r)) ?? resolve(r);
|
|
2665
|
+
if (isInsideRoot(rootReal, targetReal))
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
throw new Error(`路径越界(符号链接指向授权范围之外):${abs}`);
|
|
2669
|
+
};
|
|
2016
2670
|
const crossDirFs = {
|
|
2017
2671
|
list: async (absDir) => {
|
|
2018
2672
|
const abs = allowAbs(absDir);
|
|
@@ -2027,11 +2681,13 @@ export class PluginManager {
|
|
|
2027
2681
|
},
|
|
2028
2682
|
write: async (absPath, data) => {
|
|
2029
2683
|
const abs = allowAbs(absPath);
|
|
2684
|
+
await assertRealInsideGrant(abs);
|
|
2030
2685
|
await mkdir(dirname(abs), { recursive: true });
|
|
2031
2686
|
await writeFile(abs, data);
|
|
2032
2687
|
},
|
|
2033
2688
|
remove: async (absPath) => {
|
|
2034
2689
|
const abs = allowAbs(absPath);
|
|
2690
|
+
await assertRealInsideGrant(abs);
|
|
2035
2691
|
await rm(abs, { recursive: true, force: true });
|
|
2036
2692
|
},
|
|
2037
2693
|
stat: async (absPath) => {
|
|
@@ -2155,6 +2811,18 @@ export class PluginManager {
|
|
|
2155
2811
|
toolHandlers.add(h);
|
|
2156
2812
|
return () => toolHandlers.delete(h);
|
|
2157
2813
|
},
|
|
2814
|
+
onToolPre: (h) => {
|
|
2815
|
+
if (!can("tools"))
|
|
2816
|
+
return () => { };
|
|
2817
|
+
preGuards.add(h);
|
|
2818
|
+
return () => preGuards.delete(h);
|
|
2819
|
+
},
|
|
2820
|
+
onToolPost: (h) => {
|
|
2821
|
+
if (!can("tools"))
|
|
2822
|
+
return () => { };
|
|
2823
|
+
postGuards.add(h);
|
|
2824
|
+
return () => postGuards.delete(h);
|
|
2825
|
+
},
|
|
2158
2826
|
onRunEvent: (h) => {
|
|
2159
2827
|
runHandlers.add(h);
|
|
2160
2828
|
return () => runHandlers.delete(h);
|
|
@@ -2366,13 +3034,7 @@ export class PluginManager {
|
|
|
2366
3034
|
},
|
|
2367
3035
|
registerCommand: (cmd) => {
|
|
2368
3036
|
const off = this.registerCommand(info.id, cmd);
|
|
2369
|
-
|
|
2370
|
-
return () => {
|
|
2371
|
-
const i = unregisterCommands.indexOf(off);
|
|
2372
|
-
if (i >= 0)
|
|
2373
|
-
unregisterCommands.splice(i, 1);
|
|
2374
|
-
off();
|
|
2375
|
-
};
|
|
3037
|
+
return effects.add(`command:/${String(cmd?.name ?? "?").replace(/^\/+/, "")}`, off);
|
|
2376
3038
|
},
|
|
2377
3039
|
storage,
|
|
2378
3040
|
secrets,
|
|
@@ -2389,8 +3051,9 @@ export class PluginManager {
|
|
|
2389
3051
|
self.pushRuntimeDiag(info.id, `route: invalid method/path (method=${String(method)} path=${String(path)}), ignored`);
|
|
2390
3052
|
return () => { };
|
|
2391
3053
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
3054
|
+
const key = `${m} ${path}`;
|
|
3055
|
+
httpRoutes.set(key, handler);
|
|
3056
|
+
return effects.add(`route:${key}`, () => httpRoutes.delete(key));
|
|
2394
3057
|
},
|
|
2395
3058
|
registerProxy: (prefix, target) => {
|
|
2396
3059
|
if (!can("http"))
|
|
@@ -2401,22 +3064,16 @@ export class PluginManager {
|
|
|
2401
3064
|
self.pushRuntimeDiag(info.id, `registerProxy: invalid prefix/target or taken (prefix=${String(prefix)}), ignored`);
|
|
2402
3065
|
return () => { };
|
|
2403
3066
|
}
|
|
2404
|
-
return () => {
|
|
3067
|
+
return effects.add(`proxy:${p}`, () => {
|
|
2405
3068
|
self.unregisterProxy(info.id, p);
|
|
2406
|
-
};
|
|
3069
|
+
});
|
|
2407
3070
|
},
|
|
2408
3071
|
// 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
|
|
2409
3072
|
registerAgentTool: (tool) => {
|
|
2410
3073
|
if (!can("tools"))
|
|
2411
3074
|
return () => { };
|
|
2412
3075
|
const off = this.registerAgentTool(info.id, tool);
|
|
2413
|
-
|
|
2414
|
-
return () => {
|
|
2415
|
-
const i = unregisterTools.indexOf(off);
|
|
2416
|
-
if (i >= 0)
|
|
2417
|
-
unregisterTools.splice(i, 1);
|
|
2418
|
-
off();
|
|
2419
|
-
};
|
|
3076
|
+
return effects.add(`agentTool:${String(tool?.name ?? "?")}`, off);
|
|
2420
3077
|
},
|
|
2421
3078
|
dir,
|
|
2422
3079
|
dataDir: this.dataDir,
|
|
@@ -2495,8 +3152,7 @@ export class PluginManager {
|
|
|
2495
3152
|
/* already closed */
|
|
2496
3153
|
}
|
|
2497
3154
|
};
|
|
2498
|
-
|
|
2499
|
-
return off;
|
|
3155
|
+
return effects.add(`watch:${String(relPath)}`, off);
|
|
2500
3156
|
},
|
|
2501
3157
|
},
|
|
2502
3158
|
project: {
|
|
@@ -2543,6 +3199,13 @@ export class PluginManager {
|
|
|
2543
3199
|
}
|
|
2544
3200
|
};
|
|
2545
3201
|
fire();
|
|
3202
|
+
effects.add(`bgTask:${id}`, () => {
|
|
3203
|
+
if (bgTaskTable.delete(id)) {
|
|
3204
|
+
if (bgTaskTable.size === 0)
|
|
3205
|
+
self.pluginBgTasks.delete(info.id);
|
|
3206
|
+
fire();
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
2546
3209
|
return {
|
|
2547
3210
|
update: (next) => {
|
|
2548
3211
|
if (!bgTaskTable.has(id))
|
|
@@ -2600,13 +3263,13 @@ export class PluginManager {
|
|
|
2600
3263
|
}
|
|
2601
3264
|
if (added.length)
|
|
2602
3265
|
void self.pushToAll().catch(() => { });
|
|
2603
|
-
return () => {
|
|
3266
|
+
return effects.add(`ui:register(${added.join(",") || "none"})`, () => {
|
|
2604
3267
|
if (!added.length)
|
|
2605
3268
|
return;
|
|
2606
3269
|
for (const id of added)
|
|
2607
3270
|
self.removeUiItem(info.id, id);
|
|
2608
3271
|
void self.pushToAll().catch(() => { });
|
|
2609
|
-
};
|
|
3272
|
+
});
|
|
2610
3273
|
},
|
|
2611
3274
|
update: (id, patch) => {
|
|
2612
3275
|
if (!can("ui"))
|
|
@@ -2618,8 +3281,23 @@ export class PluginManager {
|
|
|
2618
3281
|
self.pushRuntimeDiag(info.id, `ui.update: unknown id "${String(id).slice(0, 32)}", ignored`);
|
|
2619
3282
|
return;
|
|
2620
3283
|
}
|
|
2621
|
-
|
|
2622
|
-
|
|
3284
|
+
// 与注册同一条校验管线:patch 合进 base 后整体过 parseUiItem,
|
|
3285
|
+
// 非法字段按 parse 语义丢弃/回落(未知键清掉、iconSvg 重新归一化、
|
|
3286
|
+
// 超长截断、progress 夹取),id 与 slot 不可被 patch 改写。
|
|
3287
|
+
const diags = [];
|
|
3288
|
+
const mergedRaw = typeof patch === "object" && patch !== null && !Array.isArray(patch)
|
|
3289
|
+
? { ...base, ...patch, id, slot: base.slot }
|
|
3290
|
+
: { ...base, id, slot: base.slot };
|
|
3291
|
+
const parsed = parseUiItem(mergedRaw, base.slot, diags);
|
|
3292
|
+
for (const m of diags)
|
|
3293
|
+
self.pushRuntimeDiag(info.id, `ui.update: ${m}`);
|
|
3294
|
+
if (!parsed) {
|
|
3295
|
+
// 合并结果不合法(典型:patch 把 label 改成空)——按现有 parse 语义
|
|
3296
|
+
// 视为无效更新,保留原条目。
|
|
3297
|
+
self.pushRuntimeDiag(info.id, `ui.update: merged item invalid, update ignored`);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
self.uiRuntimeFor(info.id).items.set(id, parsed);
|
|
2623
3301
|
void self.pushToAll().catch(() => { });
|
|
2624
3302
|
},
|
|
2625
3303
|
remove: (id) => {
|
|
@@ -2643,7 +3321,13 @@ export class PluginManager {
|
|
|
2643
3321
|
},
|
|
2644
3322
|
list: () => self.uiOf(info.id) ?? { items: [], arrange: [] },
|
|
2645
3323
|
},
|
|
2646
|
-
getSettings: () =>
|
|
3324
|
+
getSettings: () => {
|
|
3325
|
+
// P2-9:运行时视角同样走三层合并(警告已在 scan 落诊断,这里静默用值)。
|
|
3326
|
+
const schema = info.settingsSchema ?? [];
|
|
3327
|
+
const overlayFile = readSettingsOverlayFile(self.dataDir, info.id);
|
|
3328
|
+
const overlay = cleanOverlaySettings(schema, overlayFile.raw, "en");
|
|
3329
|
+
return runtimeSettingsValues(dir, schema, secrets, overlay.values);
|
|
3330
|
+
},
|
|
2647
3331
|
onSettingsChanged: (h) => {
|
|
2648
3332
|
settingsHandlers.add(h);
|
|
2649
3333
|
return () => settingsHandlers.delete(h);
|
|
@@ -2915,7 +3599,7 @@ export class PluginManager {
|
|
|
2915
3599
|
}
|
|
2916
3600
|
};
|
|
2917
3601
|
// 反激活只停表、不断持久化:下次 activate 重调 schedule() 即按落盘声明重建。
|
|
2918
|
-
|
|
3602
|
+
effects.add(`schedule:${sid}`, cancelTimer);
|
|
2919
3603
|
return off;
|
|
2920
3604
|
},
|
|
2921
3605
|
models: {
|
|
@@ -2938,64 +3622,27 @@ export class PluginManager {
|
|
|
2938
3622
|
},
|
|
2939
3623
|
onStats: (h) => {
|
|
2940
3624
|
self.statsHandlers.add(h);
|
|
2941
|
-
|
|
3625
|
+
return effects.add("onStats", () => {
|
|
2942
3626
|
self.statsHandlers.delete(h);
|
|
2943
|
-
};
|
|
2944
|
-
statsSubs.push(off);
|
|
2945
|
-
return () => {
|
|
2946
|
-
const i = statsSubs.indexOf(off);
|
|
2947
|
-
if (i >= 0)
|
|
2948
|
-
statsSubs.splice(i, 1);
|
|
2949
|
-
off();
|
|
2950
|
-
};
|
|
3627
|
+
});
|
|
2951
3628
|
},
|
|
2952
3629
|
onStreaming: (h) => {
|
|
2953
3630
|
self.streamingHandlers.add(h);
|
|
2954
|
-
|
|
3631
|
+
return effects.add("onStreaming", () => {
|
|
2955
3632
|
self.streamingHandlers.delete(h);
|
|
2956
|
-
};
|
|
2957
|
-
streamingSubs.push(off);
|
|
2958
|
-
return () => {
|
|
2959
|
-
const i = streamingSubs.indexOf(off);
|
|
2960
|
-
if (i >= 0)
|
|
2961
|
-
streamingSubs.splice(i, 1);
|
|
2962
|
-
off();
|
|
2963
|
-
};
|
|
3633
|
+
});
|
|
2964
3634
|
},
|
|
2965
3635
|
net: {
|
|
2966
3636
|
fetch: async (url, init) => {
|
|
2967
3637
|
if (!can("net"))
|
|
2968
3638
|
return { ok: false, error: '插件未声明能力 "net"(manifest.permissions)——请求被拒' };
|
|
2969
|
-
|
|
2970
|
-
const u = new URL(String(url));
|
|
2971
|
-
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
2972
|
-
return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
|
|
2973
|
-
}
|
|
3639
|
+
return pluginNetFetch(url, init, {
|
|
2974
3640
|
// 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。
|
|
2975
3641
|
// 用户动态批准的主机(host.requestPermission)同样放行,免改 manifest 重装。
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
return {
|
|
2981
|
-
ok: false,
|
|
2982
|
-
error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
|
|
2983
|
-
};
|
|
2984
|
-
if (init?.body !== undefined && Buffer.byteLength(String(init.body), "utf8") > 1024 * 1024) {
|
|
2985
|
-
return { ok: false, error: "net: body 超过 1MB 上限" };
|
|
2986
|
-
}
|
|
2987
|
-
const res = await globalThis.fetch(String(url), {
|
|
2988
|
-
method: init?.method ?? "GET",
|
|
2989
|
-
...(init?.headers ? { headers: init.headers } : {}),
|
|
2990
|
-
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
2991
|
-
signal: AbortSignal.timeout(15_000),
|
|
2992
|
-
});
|
|
2993
|
-
const text = (await res.text()).slice(0, 512 * 1024);
|
|
2994
|
-
return { ok: true, status: res.status, text };
|
|
2995
|
-
}
|
|
2996
|
-
catch (err) {
|
|
2997
|
-
return { ok: false, error: err.message };
|
|
2998
|
-
}
|
|
3642
|
+
hostAllowed: (hostname) => netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) ||
|
|
3643
|
+
self.permGrants.has(info.id, "net", { host: hostname }),
|
|
3644
|
+
fetchImpl: (input, reqInit) => globalThis.fetch(input, reqInit),
|
|
3645
|
+
});
|
|
2999
3646
|
},
|
|
3000
3647
|
},
|
|
3001
3648
|
events: {
|
|
@@ -3038,18 +3685,11 @@ export class PluginManager {
|
|
|
3038
3685
|
if (!set)
|
|
3039
3686
|
self.busHandlers.set(t, (set = new Set()));
|
|
3040
3687
|
set.add(handler);
|
|
3041
|
-
|
|
3688
|
+
return effects.add(`bus:${t}`, () => {
|
|
3042
3689
|
set.delete(handler);
|
|
3043
3690
|
if (set.size === 0)
|
|
3044
3691
|
self.busHandlers.delete(t);
|
|
3045
|
-
};
|
|
3046
|
-
busSubs.push(off);
|
|
3047
|
-
return () => {
|
|
3048
|
-
const i = busSubs.indexOf(off);
|
|
3049
|
-
if (i >= 0)
|
|
3050
|
-
busSubs.splice(i, 1);
|
|
3051
|
-
off();
|
|
3052
|
-
};
|
|
3692
|
+
});
|
|
3053
3693
|
},
|
|
3054
3694
|
},
|
|
3055
3695
|
log: (levelOrArg, ...args) => {
|
|
@@ -3068,6 +3708,14 @@ export class PluginManager {
|
|
|
3068
3708
|
else
|
|
3069
3709
|
console.log(line);
|
|
3070
3710
|
},
|
|
3711
|
+
effect: (label, dispose) => {
|
|
3712
|
+
if (typeof dispose !== "function")
|
|
3713
|
+
return () => { };
|
|
3714
|
+
const name = String(label ?? "")
|
|
3715
|
+
.trim()
|
|
3716
|
+
.slice(0, 64) || "anonymous";
|
|
3717
|
+
return effects.add(`plugin:${name}`, dispose);
|
|
3718
|
+
},
|
|
3071
3719
|
};
|
|
3072
3720
|
try {
|
|
3073
3721
|
// Node 对同一 URL 的 import() 永远返回缓存模块——追加 epoch 作查询串
|
|
@@ -3083,17 +3731,13 @@ export class PluginManager {
|
|
|
3083
3731
|
info: { ...info, ...(combined ? { diagnostics: combined } : {}) },
|
|
3084
3732
|
deactivate: typeof ret === "function" ? ret : undefined,
|
|
3085
3733
|
toolHandlers,
|
|
3734
|
+
preGuards,
|
|
3735
|
+
postGuards,
|
|
3086
3736
|
runHandlers,
|
|
3087
3737
|
convChangeHandlers,
|
|
3088
3738
|
attachHandlers,
|
|
3089
3739
|
cwdHandlers,
|
|
3090
|
-
|
|
3091
|
-
commandUnsubscribers: unregisterCommands,
|
|
3092
|
-
watchUnsubscribers: watchSubs,
|
|
3093
|
-
scheduleUnsubscribers: scheduleSubs,
|
|
3094
|
-
busUnsubscribers: busSubs,
|
|
3095
|
-
statsUnsubscribers: statsSubs,
|
|
3096
|
-
streamingUnsubscribers: streamingSubs,
|
|
3740
|
+
effects,
|
|
3097
3741
|
httpRoutes,
|
|
3098
3742
|
permsDeclared,
|
|
3099
3743
|
permFamilies,
|
|
@@ -3108,9 +3752,14 @@ export class PluginManager {
|
|
|
3108
3752
|
void this.maybeConsentNotice(info, dir, permsDeclared);
|
|
3109
3753
|
}
|
|
3110
3754
|
catch (err) {
|
|
3755
|
+
// 激活失败:先把这一轮已经登记的可逆副作用逆序回卷(半途注册的工具/路由/
|
|
3756
|
+
// 定时器不能留着 —— 插件已经坏了,谁也不会来撤它们),再留一条错误占位行。
|
|
3757
|
+
const failed = effects.release();
|
|
3111
3758
|
httpRoutes.clear();
|
|
3112
3759
|
self.activatingGates.delete(info.id);
|
|
3113
3760
|
this.pushRuntimeDiag(info.id, `activate failed: ${err.message}`);
|
|
3761
|
+
if (failed.length > 0)
|
|
3762
|
+
this.pushRuntimeDiag(info.id, `activate failed with ${failed.length} effect(s) not cleaned: ${failed.join(", ")}`);
|
|
3114
3763
|
this.loaded.set(info.id, {
|
|
3115
3764
|
info: { ...info, error: err.message, diagnostics: this.diagnosticsOf(info.id) },
|
|
3116
3765
|
toolHandlers,
|
|
@@ -3221,3 +3870,99 @@ export function syncPluginToolsIntoSession(session, defs, prevNames) {
|
|
|
3221
3870
|
session._refreshToolRegistry();
|
|
3222
3871
|
return new Set(defs.map((d) => d.name));
|
|
3223
3872
|
}
|
|
3873
|
+
/** net.fetch 的手动重定向跳数上限。 */
|
|
3874
|
+
const NET_FETCH_MAX_REDIRECTS = 5;
|
|
3875
|
+
/**
|
|
3876
|
+
* net.fetch 的白名单 + 手动重定向循环(抽出为纯可注入函数,vitest 直测)。
|
|
3877
|
+
*
|
|
3878
|
+
* 为什么不用 redirect:"follow":跟随重定向不复查白名单,会把「只授权了 A 主机」
|
|
3879
|
+
* 变成「A 主机一跳把你带到任意主机/内网地址」(经典白名单绕过跳板)。所以:
|
|
3880
|
+
* - 每一跳(含首次)的目标 host 都重新过 allowHost(manifest 白名单 + 用户动态批准);
|
|
3881
|
+
* - redirect:"manual",至多 NET_FETCH_MAX_REDIRECTS 跳;
|
|
3882
|
+
* - 跨宿主重定向剥掉 authorization/cookie 头(浏览器同款语义,防凭据外带);
|
|
3883
|
+
* - 303 一律转 GET 并丢 body;其余 3xx 保持原 method/body(307/308 语义);
|
|
3884
|
+
* - 响应文本整体回给插件的现状保留(512KB 截断不变);
|
|
3885
|
+
* - timeout 是整条请求链(含所有跳)共享的墙钟预算(与原实现 15s 同量级)。
|
|
3886
|
+
*/
|
|
3887
|
+
export async function pluginNetFetch(url, init, deps) {
|
|
3888
|
+
const deadline = Date.now() + Math.max(1000, Number(deps.timeoutMs ?? 15_000));
|
|
3889
|
+
let current = String(url ?? "");
|
|
3890
|
+
let method = init?.method;
|
|
3891
|
+
let headers = init?.headers;
|
|
3892
|
+
let body = init?.body;
|
|
3893
|
+
for (let hop = 0;; hop++) {
|
|
3894
|
+
let u;
|
|
3895
|
+
try {
|
|
3896
|
+
u = new URL(current);
|
|
3897
|
+
}
|
|
3898
|
+
catch {
|
|
3899
|
+
return { ok: false, error: `net: 无效 URL ${current}` };
|
|
3900
|
+
}
|
|
3901
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
3902
|
+
return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
|
|
3903
|
+
}
|
|
3904
|
+
// 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。每一跳都查。
|
|
3905
|
+
if (!deps.hostAllowed(u.hostname.toLowerCase())) {
|
|
3906
|
+
return {
|
|
3907
|
+
ok: false,
|
|
3908
|
+
error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
if (body !== undefined && Buffer.byteLength(String(body), "utf8") > 1024 * 1024) {
|
|
3912
|
+
return { ok: false, error: "net: body 超过 1MB 上限" };
|
|
3913
|
+
}
|
|
3914
|
+
let res;
|
|
3915
|
+
try {
|
|
3916
|
+
res = await deps.fetchImpl(current, {
|
|
3917
|
+
...(method ? { method } : {}),
|
|
3918
|
+
...(headers ? { headers } : {}),
|
|
3919
|
+
...(body !== undefined ? { body } : {}),
|
|
3920
|
+
redirect: "manual",
|
|
3921
|
+
signal: AbortSignal.timeout(Math.max(1000, deadline - Date.now())),
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
catch (err) {
|
|
3925
|
+
return { ok: false, error: err.message };
|
|
3926
|
+
}
|
|
3927
|
+
// 3xx 且带 location → 手动跳下一跳(目标 host 重新过上面的白名单)。
|
|
3928
|
+
if (res.status >= 300 && res.status < 400) {
|
|
3929
|
+
const loc = res.headers.get("location");
|
|
3930
|
+
if (loc) {
|
|
3931
|
+
if (hop >= NET_FETCH_MAX_REDIRECTS) {
|
|
3932
|
+
return { ok: false, error: `net: 重定向超过 ${NET_FETCH_MAX_REDIRECTS} 跳上限` };
|
|
3933
|
+
}
|
|
3934
|
+
let next;
|
|
3935
|
+
try {
|
|
3936
|
+
next = new URL(loc, u);
|
|
3937
|
+
}
|
|
3938
|
+
catch {
|
|
3939
|
+
return { ok: false, error: `net: 重定向目标无效 ${loc}` };
|
|
3940
|
+
}
|
|
3941
|
+
if (next.hostname.toLowerCase() !== u.hostname.toLowerCase() && headers) {
|
|
3942
|
+
// 跨宿主:凭据类头不外带。
|
|
3943
|
+
const stripped = {};
|
|
3944
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
3945
|
+
const key = k.toLowerCase();
|
|
3946
|
+
if (key === "authorization" || key === "cookie")
|
|
3947
|
+
continue;
|
|
3948
|
+
stripped[k] = v;
|
|
3949
|
+
}
|
|
3950
|
+
headers = stripped;
|
|
3951
|
+
}
|
|
3952
|
+
if (res.status === 303) {
|
|
3953
|
+
method = "GET";
|
|
3954
|
+
body = undefined;
|
|
3955
|
+
}
|
|
3956
|
+
current = next.toString();
|
|
3957
|
+
continue;
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
try {
|
|
3961
|
+
const text = (await res.text()).slice(0, 512 * 1024);
|
|
3962
|
+
return { ok: true, status: res.status, text };
|
|
3963
|
+
}
|
|
3964
|
+
catch (err) {
|
|
3965
|
+
return { ok: false, error: err.message };
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}
|