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.
Files changed (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,296 @@
1
+ export const PLUGIN_API_CATALOG_VERSION = 1;
2
+ /** 每个 slot 的 manifest 最小例子(与 parseUiContributions 同口径,别名已展开写法也收)。 */
3
+ export const SLOT_EXAMPLES = {
4
+ "topbar.primary": `{ "topbar": [{ "id": "inbox", "label": "收件箱", "kind": "action", "action": "my:open" }] }`,
5
+ "topbar.overflow": `{ "topbar.more": [{ "id": "about", "label": "关于", "kind": "action", "action": "my:about" }] }`,
6
+ bottombar: `{ "ui": { "bottombar": [{ "id": "st", "label": "状态", "kind": "action", "action": "my:st" }] } }`,
7
+ "composer.leading": `{ "ui": { "composer.leading": [{ "id": "m", "label": "+", "kind": "action", "action": "my:m" }] } }`,
8
+ "composer.actions": `{ "composer": [{ "id": "pick", "label": "拾取", "kind": "action", "action": "my:pick" }] }`,
9
+ "message.actions": `{ "message": [{ "id": "send", "label": "转存", "kind": "action", "action": "my:send" }] }`,
10
+ "rightpanel.tabs": `{ "rightpanel": [{ "id": "mail", "label": "邮件", "kind": "view" }] }`,
11
+ "contextmenu.topbar": `{ "ui": { "contextmenu.topbar": [{ "id": "m", "label": "顶栏菜单", "kind": "action", "action": "my:m" }] } }`,
12
+ "contextmenu.message": `{ "ui": { "contextmenu.message": [{ "id": "m", "label": "消息菜单", "kind": "action", "action": "my:m" }] } }`,
13
+ "contextmenu.session": `{ "ui": { "contextmenu.session": [{ "id": "m", "label": "会话菜单", "kind": "action", "action": "my:m" }] } }`,
14
+ "contextmenu.file": `{ "contextmenu.file": [{ "id": "mail", "label": "发到邮箱", "kind": "action", "action": "my:send" }] }`,
15
+ "contextmenu.toolcall": `{ "ui": { "contextmenu.toolcall": [{ "id": "m", "label": "工具菜单", "kind": "action", "action": "my:m" }] } }`,
16
+ "settings.pages": `{ "settings": [{ "id": "conf", "label": "邮箱设置", "kind": "page" }] }`,
17
+ "leftpanel.sessions": `{ "ui": { "leftpanel.sessions": [{ "id": "m", "label": "会话区", "kind": "action", "action": "my:m" }] } }`,
18
+ "chat.header": `{ "ui": { "chat.header": [{ "id": "m", "label": "会话题头", "kind": "action", "action": "my:m" }] } }`,
19
+ "chat.empty": `{ "ui": { "chat.empty": [{ "id": "m", "label": "空态", "kind": "action", "action": "my:m" }] } }`,
20
+ "file.preview.toolbar": `{ "ui": { "file.preview.toolbar": [{ "id": "m", "label": "预览工具", "kind": "action", "action": "my:m" }] } }`,
21
+ "terminal.toolbar": `{ "ui": { "terminal.toolbar": [{ "id": "m", "label": "终端工具", "kind": "action", "action": "my:m" }] } }`,
22
+ "scm.toolbar": `{ "ui": { "scm.toolbar": [{ "id": "m", "label": "SCM 工具", "kind": "action", "action": "my:m" }] } }`,
23
+ "goalbar.actions": `{ "ui": { "goalbar.actions": [{ "id": "m", "label": "目标条", "kind": "action", "action": "my:m" }] } }`,
24
+ "notice.actions": `{ "ui": { "notice.actions": [{ "id": "m", "label": "通知动作", "kind": "action", "action": "my:m" }] } }`,
25
+ "modal.dialog": `{ "modal": [{ "id": "m", "label": "弹窗", "kind": "action", "action": "my:m" }] }`,
26
+ };
27
+ /** 宿主方法表(与 server/plugins.ts#PluginHost 同语义的精简版:只收发现用的注册/调用面)。
28
+ * needs="-" = 观察/基础设施类(无需能力声明);其余走 can() 门控。 */
29
+ export const HOST_METHODS = [
30
+ {
31
+ name: "ui.register",
32
+ needs: "ui",
33
+ summary: "运行时注册 UI 条目(与 manifest 同口径,别名映射+枚举校验)",
34
+ example: `host.ui.register({ slot: "topbar.primary", id: "btn", label: "按钮" })`,
35
+ },
36
+ {
37
+ name: "ui.update",
38
+ needs: "ui",
39
+ summary: "按 id 改已存在条目(label/badge/checked/value/progress)",
40
+ example: `host.ui.update("btn", { badge: "3" })`,
41
+ },
42
+ {
43
+ name: "ui.remove",
44
+ needs: "ui",
45
+ summary: "移除条目(manifest 声明的也能压住,直到 reload)",
46
+ example: `host.ui.remove("btn")`,
47
+ },
48
+ {
49
+ name: "ui.arrange",
50
+ needs: "ui",
51
+ summary: "整理任何条目(含 host:* 内置):hide/order/group/label",
52
+ example: `host.ui.arrange([{ id: "host:tasks", hide: true }])`,
53
+ },
54
+ {
55
+ name: "ui.list",
56
+ needs: "ui",
57
+ summary: "自查本插件的 items + arrange(调别的插件看不见)",
58
+ example: `host.ui.list()`,
59
+ },
60
+ {
61
+ name: "onUiAction",
62
+ needs: "ui",
63
+ summary: "订阅条目点击(action 名 → 回调,client bundle 侧)",
64
+ example: `__piWebUiHost.onUiAction("my:open", () => {})`,
65
+ },
66
+ {
67
+ name: "registerAgentTool",
68
+ needs: "tools",
69
+ summary: "给 AI 注册工具(插件给模型加能力,与 AI 写插件方向相反)",
70
+ example: `host.registerAgentTool({ name: "mail_list", description: "…", execute: async () => ({}) })`,
71
+ },
72
+ {
73
+ name: "onToolPre",
74
+ needs: "tools",
75
+ summary: "工具 pre 拦截(仅 bash/read):allow/deny/ask,首个阻断胜出",
76
+ example: `host.onToolPre((req) => req.params?.command?.includes("rm -rf") ? { decision: "deny" } : undefined)`,
77
+ },
78
+ {
79
+ name: "onToolPost",
80
+ needs: "tools",
81
+ summary: "工具 post 编辑(仅 bash/read):换 content / 补 additionalContext",
82
+ example: `host.onToolPost(({ result }) => ({ content: result.content }))`,
83
+ },
84
+ {
85
+ name: "onToolEvent",
86
+ needs: "-",
87
+ summary: "订阅工具执行事件(start/end 成对,只观测)",
88
+ example: `host.onToolEvent((ev) => {})`,
89
+ },
90
+ {
91
+ name: "bash",
92
+ needs: "tools",
93
+ summary: "受限 shell(无 piped shell,按词切分;cwd 锁工作区内,默认 60s 超时)",
94
+ example: `await host.bash("ls", { timeoutMs: 10000 })`,
95
+ },
96
+ {
97
+ name: "registerCommand",
98
+ needs: "-",
99
+ summary: "注册斜杠命令(/name 选择器 + prompt 拦截执行)",
100
+ example: `host.registerCommand({ name: "deploy", description: "部署", run: (args) => {} })`,
101
+ },
102
+ {
103
+ name: "route",
104
+ needs: "http",
105
+ summary: "挂载 HTTP 路由(/plugins-api/<id><path>)",
106
+ example: `host.route("GET", "/inbox", (req, res) => res.json([]))`,
107
+ },
108
+ {
109
+ name: "registerProxy",
110
+ needs: "http",
111
+ summary: "反向代理前缀(透传到 127.0.0.1:port,防 SSRF 锁回环)",
112
+ example: `host.registerProxy("/app", 3000)`,
113
+ },
114
+ {
115
+ name: "chat",
116
+ needs: "chat",
117
+ summary: "无头调用:把外部通道文本投给 agent(fire-and-forget)",
118
+ example: `await host.chat({ text: "hi" })`,
119
+ },
120
+ {
121
+ name: "chatWait",
122
+ needs: "chat",
123
+ summary: "等无头调用的 run_end(默认 120s,上下钳制)",
124
+ example: `await host.chatWait({ conversationId })`,
125
+ },
126
+ {
127
+ name: "llm.complete",
128
+ needs: "llm",
129
+ summary: "孤立无工具一次性补全(花用户模型额度)",
130
+ example: `await host.llm.complete({ prompt: "…" })`,
131
+ },
132
+ {
133
+ name: "requestPermission",
134
+ needs: "-",
135
+ summary: "申请能力授权(net 主机 / llm 模型作用域,用户确认)",
136
+ example: `await host.requestPermission({ family: "net", hosts: ["api.example.com"] })`,
137
+ },
138
+ {
139
+ name: "fs",
140
+ needs: "fs",
141
+ summary: "工作区文件(list/read/readText/write/remove…,锚定活 cwd,越界拒绝)",
142
+ example: `await host.fs.readText("notes/todo.md")`,
143
+ },
144
+ {
145
+ name: "fs.requestAccess",
146
+ needs: "fs",
147
+ summary: "申请工作区外目录授权(浏览器确认框,remember 记盘)",
148
+ example: `await host.fs.requestAccess("/data")`,
149
+ },
150
+ {
151
+ name: "project.create",
152
+ needs: "fs",
153
+ summary: "在已授权目录组装项目(mkdir/clone/写文件/git init)",
154
+ example: `await host.project.create({ dir: "/data/app", files: {} })`,
155
+ },
156
+ {
157
+ name: "schedule",
158
+ needs: "-",
159
+ summary: "定时任务(cron/延迟,持久化可重启不丢,远期分片防溢出)",
160
+ example: `host.schedule("0 9 * * *", () => {})`,
161
+ },
162
+ {
163
+ name: "registerBackgroundTask",
164
+ needs: "-",
165
+ summary: "常驻后台任务(并入后台任务面板,随反激活移除)",
166
+ example: `host.registerBackgroundTask({ id: "svc", label: "服务" })`,
167
+ },
168
+ {
169
+ name: "onRunEvent",
170
+ needs: "-",
171
+ summary: "订阅运行轨迹(run/message/tool…,时间线聚合用)",
172
+ example: `host.onRunEvent((ev) => {})`,
173
+ },
174
+ {
175
+ name: "getActiveConversation",
176
+ needs: "-",
177
+ summary: "读当前打开对话快照(只读引用,广播前抽摘要)",
178
+ example: `host.getActiveConversation()`,
179
+ },
180
+ {
181
+ name: "onConversationChanged",
182
+ needs: "-",
183
+ summary: "订阅切对话(轨迹插件重拉时间线)",
184
+ example: `host.onConversationChanged(() => {})`,
185
+ },
186
+ {
187
+ name: "storage",
188
+ needs: "-",
189
+ summary: "插件私有 KV(storage.json,原子写,卸载即删)",
190
+ example: `host.storage.get("k")`,
191
+ },
192
+ { name: "secrets", needs: "-", summary: "加密 secrets(明文永不落盘/下发)", example: `host.secrets.get("token")` },
193
+ {
194
+ name: "ensureDeps",
195
+ needs: "-",
196
+ summary: "依赖自动补装(单飞,npm 包)",
197
+ example: `await host.ensureDeps(["dayjs"])`,
198
+ },
199
+ {
200
+ name: "getSettings",
201
+ needs: "-",
202
+ summary: "读声明式设置值(manifest.settings 字段)",
203
+ example: `host.getSettings()`,
204
+ },
205
+ {
206
+ name: "onSettingsChanged",
207
+ needs: "-",
208
+ summary: "订阅设置保存(⚙ 面板保存后触发)",
209
+ example: `host.onSettingsChanged((v) => {})`,
210
+ },
211
+ { name: "onCwdChange", needs: "-", summary: "订阅工作区切换", example: `host.onCwdChange((cwd) => {})` },
212
+ {
213
+ name: "onAttach",
214
+ needs: "-",
215
+ summary: "订阅浏览器接入(主动推完整状态,服务端是唯一事实源)",
216
+ example: `host.onAttach((clientId) => {})`,
217
+ },
218
+ {
219
+ name: "broadcast",
220
+ needs: "-",
221
+ summary: "向所有浏览器广播本插件消息",
222
+ example: `host.broadcast({ kind: "state" })`,
223
+ },
224
+ { name: "notify", needs: "-", summary: "发系统通知条(notice toast)", example: `host.notify("info", "完成")` },
225
+ {
226
+ name: "effect",
227
+ needs: "-",
228
+ summary: "自建副作用挂进 effect 栈(反激活逆序回卷)",
229
+ example: `host.effect("timer", () => clearInterval(t))`,
230
+ },
231
+ {
232
+ name: "events.emit/on",
233
+ needs: "-",
234
+ summary: "插件间事件总线(topic 建议 <id>: 前缀,发送方不可伪造)",
235
+ example: `host.events.on("notes:changed", () => {})`,
236
+ },
237
+ { name: "models.list", needs: "-", summary: "列出已配置鉴权的模型(provider/id)", example: `host.models.list()` },
238
+ {
239
+ name: "scm",
240
+ needs: "-",
241
+ summary: "只读 git 查询(status/branches/history,不经过 shell)",
242
+ example: `await host.scm.status()`,
243
+ },
244
+ {
245
+ name: "net.fetch",
246
+ needs: "net",
247
+ summary: "出站网络(白名单命中才放行,失败回 {ok:false} 不抛)",
248
+ example: `await host.net.fetch("https://api.example.com/x")`,
249
+ },
250
+ {
251
+ name: "conversations",
252
+ needs: "-",
253
+ summary: "会话目录(list/get/search,只读组装+定向投递)",
254
+ example: `await host.conversations.list()`,
255
+ },
256
+ {
257
+ name: "prompt",
258
+ needs: "-",
259
+ summary: "向指定对话发一条用户消息(无注入回 ok:false)",
260
+ example: `await host.prompt(convId, { text: "hi" })`,
261
+ },
262
+ { name: "steer", needs: "-", summary: "插队指定对话的当前运行", example: `await host.steer(convId, "换方向")` },
263
+ { name: "abortRun", needs: "-", summary: "中止指定对话的运行", example: `await host.abortRun(convId)` },
264
+ ];
265
+ /** 装配目录:静态表(本模块)+ 动态占用(调用方现算)。调用方保证入参即真相(别名/枚举与解析层同源)。 */
266
+ export function buildPluginApiCatalog(opts) {
267
+ const aliasByTarget = new Map();
268
+ for (const [alias, target] of Object.entries(opts.aliases)) {
269
+ const list = aliasByTarget.get(target) ?? [];
270
+ list.push(alias);
271
+ aliasByTarget.set(target, list);
272
+ }
273
+ const slots = [...opts.slots].sort().map((slot) => {
274
+ const example = SLOT_EXAMPLES[slot] ??
275
+ `{ "ui": { "${slot}": [{ "id": "m", "label": "条目", "kind": "action", "action": "my:m" }] } }`;
276
+ return {
277
+ slot,
278
+ aliases: [...(aliasByTarget.get(slot) ?? [])].sort(),
279
+ kinds: [...opts.kinds].sort(),
280
+ occupants: [...opts.occupantsOf(slot)].sort((a, b) => (a.pluginId < b.pluginId ? -1 : 1)),
281
+ replaceRisk: "插件 arrange 可改任何条目(含 host:* 内置),用户偏好最后说话(布局页可恢复)",
282
+ example,
283
+ };
284
+ });
285
+ return {
286
+ version: PLUGIN_API_CATALOG_VERSION,
287
+ slots,
288
+ agentTools: opts.agentTools.map((t) => ({
289
+ name: t.name,
290
+ group: t.group,
291
+ defaultOn: t.defaultOn,
292
+ dshVisible: t.dshVisible,
293
+ })),
294
+ hostMethods: HOST_METHODS.map((m) => ({ ...m })),
295
+ };
296
+ }
@@ -16,14 +16,22 @@
16
16
  * 不因为一条坏条目就停掉整批;
17
17
  * 3. 回执是结构化的(ok/error/entries/installed),不靠 DOM 事件或控制台文字。
18
18
  */
19
- import { readFileSync, existsSync } from "node:fs";
20
- import { isAbsolute, join } from "node:path";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
21
  import { pick } from "./i18n.js";
22
22
  import { normalizeSyncPayload, writeCustomCatalog } from "./plugin-catalog.js";
23
+ import { workspacePath } from "./files-service.js";
23
24
  /**
24
- * 读同步文档:`http(s)://` 走网络,其余当本地文件路径(须为绝对路径)。
25
- * 只读文本,JSON 解析交给调用方(解析失败也走同一条「不写盘」的路径)。
25
+ * 读同步文档:`http(s)://` 走网络,其余当本地文件路径 —— **只允许工作区内的路径**
26
+ * (复用 files-service 的 workspacePath 校验;任意绝对路径可读会成为「文件存在 /
27
+ * 可读性」探测 oracle)。只读文本;本地来源的读失败与过大统一进 `localDocFailed`
28
+ * 这一条错误(调用方把解析失败也并进来),不区分「读不到 / 解析失败」两种失败。
26
29
  */
30
+ function localDocFailed(l) {
31
+ return {
32
+ error: pick(l, "目录文档无效或不可读(仅支持 http(s) URL 或工作区内的 JSON 文件)", "Invalid or unreadable catalog document (http(s) URL or an in-workspace JSON file only)", "plugincatalog.sync.doc.invalid"),
33
+ };
34
+ }
27
35
  async function readDocument(source, deps, lang) {
28
36
  const l = lang();
29
37
  const maxBytes = Math.max(1024, Number(deps.maxBytes ?? 1024 * 1024));
@@ -51,24 +59,21 @@ async function readDocument(source, deps, lang) {
51
59
  };
52
60
  return { text };
53
61
  }
54
- const p = source.trim();
55
- if (!isAbsolute(p))
56
- return {
57
- error: pick(l, "来源需为 http(s) URL 或本地文件的绝对路径", "Source must be an http(s) URL or an absolute local file path", "plugincatalog.sync.source.invalid"),
58
- };
62
+ // 本地来源:必须在工作区内(workspacePath 对相对/绝对路径统一做越界判定)。
63
+ // 越界 / 读不到 / 过大统一返回同一条错误,不给调用方区分的余地。
64
+ const wp = deps.workspaceRoot ? workspacePath(deps.workspaceRoot, source.trim()) : null;
65
+ if (!wp)
66
+ return localDocFailed(l);
67
+ let text;
59
68
  try {
60
- const text = readFileSync(p, "utf8");
61
- if (text.length > maxBytes)
62
- return {
63
- error: pick(l, `目录文档过大(> ${Math.round(maxBytes / 1024)} KB)`, `Catalog document too large (> ${Math.round(maxBytes / 1024)} KB)`, "plugincatalog.sync.too.large", { kb: String(Math.round(maxBytes / 1024)) }),
64
- };
65
- return { text };
69
+ text = readFileSync(wp.abs, "utf8");
66
70
  }
67
- catch (err) {
68
- return {
69
- error: pick(l, `读取目录文件失败:${err?.message ?? err}`, `Failed to read the catalog file: ${err?.message ?? err}`, "plugincatalog.sync.read.failed", { reason: String(err?.message ?? err) }),
70
- };
71
+ catch {
72
+ return localDocFailed(l);
71
73
  }
74
+ if (text.length > maxBytes)
75
+ return localDocFailed(l);
76
+ return { text };
72
77
  }
73
78
  /**
74
79
  * 同步一次目录:读 → 校验 → 原子写 → 可选安装 → 重载 + 重推。
@@ -86,11 +91,16 @@ export async function syncPluginCatalog(source, opts, deps) {
86
91
  const doc = await readDocument(src, deps, lang);
87
92
  if ("error" in doc)
88
93
  return { ok: false, error: doc.error };
94
+ const isLocalSource = !/^https?:\/\//i.test(src);
89
95
  let raw;
90
96
  try {
91
97
  raw = JSON.parse(doc.text);
92
98
  }
93
99
  catch (err) {
100
+ // 本地来源不区分「读得到但解析失败 / 读不到 / 越界」——组合起来就是文件探测
101
+ // oracle;http(s) 来源与本地文件无关,保留解析细节方便排障。
102
+ if (isLocalSource)
103
+ return { ok: false, error: localDocFailed(lang()).error };
94
104
  return {
95
105
  ok: false,
96
106
  error: pick(l, `目录 JSON 解析失败:${err?.message ?? err}`, `Catalog JSON is not valid JSON: ${err?.message ?? err}`, "plugincatalog.sync.parse.failed", { reason: String(err?.message ?? err) }),
@@ -104,6 +114,13 @@ export async function syncPluginCatalog(source, opts, deps) {
104
114
  await deps.afterWrite();
105
115
  let installed;
106
116
  if (opts.install === true && payload.entries.length) {
117
+ // 安装确认门(P0):安装/更新插件是高风险动作,必须先经用户确认。第三方页面
118
+ // 脚本可以直发 plugin_catalog_sync,没有这道门就能静默装任意插件。拒绝 /
119
+ // 超时 / 无确认设施(无头 DSH)一律只保留目录更新,不安装。
120
+ const items = payload.entries.map((e) => ({ id: e.id, source: e.source }));
121
+ const confirmed = deps.confirmInstall ? await deps.confirmInstall(items) : false;
122
+ if (!confirmed)
123
+ return { ok: true, installRefused: true, installed: [] };
107
124
  installed = [];
108
125
  for (const e of payload.entries) {
109
126
  const action = existsSync(join(deps.pluginsDir, e.id)) ? "update" : "install";
@@ -92,7 +92,11 @@ function toEntry(raw, builtin) {
92
92
  ...(homepage ? { homepage } : {}),
93
93
  };
94
94
  }
95
- /** 读合并后的目录:builtin 在前,custom 在后(同 id 时 custom 覆盖 builtin)。 */
95
+ /** 读合并后的目录:builtin 在前,custom 在后(同 id 时 custom 覆盖 builtin)。
96
+ * custom 覆盖 builtin 时强制打 `overridesBuiltin: true` 标记(builtin 同时为
97
+ * false):显示字段(name/icon/description)来自用户可写来源,可能仿冒官方条目,
98
+ * 前端据此把它标识为「自定义覆盖」。source 始终保留真实(custom)安装来源,
99
+ * 不允许伪装成官方来源。 */
96
100
  export function readCatalog(builtinPath, customPath) {
97
101
  const out = [];
98
102
  const seen = new Set();
@@ -117,10 +121,13 @@ export function readCatalog(builtinPath, customPath) {
117
121
  if (!e)
118
122
  continue;
119
123
  const idx = out.findIndex((x) => x.id === e.id);
120
- if (idx >= 0)
121
- out[idx] = e;
122
- else
124
+ if (idx >= 0) {
125
+ // 覆盖了同名 builtin 条目:强制带标识(不信任 custom 文件里的任何自标字段)。
126
+ out[idx] = { ...e, builtin: false, overridesBuiltin: true };
127
+ }
128
+ else {
123
129
  out.push(e);
130
+ }
124
131
  seen.add(e.id);
125
132
  }
126
133
  return out;
@@ -21,6 +21,9 @@ import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as f
21
21
  import { dirname, join, relative, resolve } from "node:path";
22
22
  import { createRequire } from "node:module";
23
23
  import { spawnSync } from "node:child_process";
24
+ // 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
25
+ // 避免「同一个安全语义、两份实现」漂移。
26
+ import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
24
27
  /** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
25
28
  function atomicWrite(file, data) {
26
29
  mkdirSync(dirname(file), { recursive: true });
@@ -29,6 +32,43 @@ function atomicWrite(file, data) {
29
32
  renameSync(tmp, file);
30
33
  }
31
34
  // ---------------------------------------------------------------------------
35
+ // storage.json 的进程内「读-改-写」互斥
36
+ // ---------------------------------------------------------------------------
37
+ /** 按文件路径的 RMW 互斥链(key = resolve 后的绝对路径)。 */
38
+ const rmwChains = new Map();
39
+ /**
40
+ * 进程内「读-改-写」互斥(按文件路径的 promise 链锁)。
41
+ *
42
+ * storage.json 有两个读-改-写者:PluginStorage(插件 KV)与宿主 settings 面板的
43
+ * saveSettingsValues(plugins.ts,直写同一文件的 settings 键)。两段 RMW 必须互斥
44
+ * —— 现在两段关键区都是同步的(单线程下本就不会交错),但只要将来任何一处把
45
+ * 「读」和「写」之间插进 await,就会退回「旧快照整份回写、抹掉对方刚写的键」的
46
+ * 丢更新。两处统一收进这把锁:
47
+ * - 空链 + 同步 fn:原地直跑(不引入微任务延迟,set() 后同步 get() 立即可见,
48
+ * 语义与未加锁完全一致);fn 若返回 promise(异步写者)则占住链尾,后续排队;
49
+ * - 链忙:挂到链尾串行(前一个成功/失败都放行下一个)。
50
+ * Map 不主动清理:键数量 = 有 storage.json 的插件数,天然有界。
51
+ */
52
+ export function withFileRmwLock(file, fn) {
53
+ const key = resolve(file);
54
+ const prev = rmwChains.get(key);
55
+ if (prev) {
56
+ const queued = prev.then(fn, fn);
57
+ rmwChains.set(key, queued.then(() => undefined, () => undefined));
58
+ return queued;
59
+ }
60
+ const result = fn();
61
+ if (result instanceof Promise) {
62
+ const tail = result.then(() => undefined, () => undefined);
63
+ rmwChains.set(key, tail);
64
+ void tail.then(() => {
65
+ if (rmwChains.get(key) === tail)
66
+ rmwChains.delete(key);
67
+ });
68
+ }
69
+ return result;
70
+ }
71
+ // ---------------------------------------------------------------------------
32
72
  // storage
33
73
  // ---------------------------------------------------------------------------
34
74
  /** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
@@ -88,29 +128,37 @@ export class PluginStorage {
88
128
  set(key, value) {
89
129
  if (!key)
90
130
  throw new Error("storage.set: key 不能为空");
91
- // 写前必重读磁盘:宿主 settings 面板的 saveSettingsValues 直写同一文件的
92
- // settings 键(不经过本缓存),拿旧快照整份回写会把它抹掉。写是低频路径,
93
- // 重读的代价可忽略;mtime 粒度即使同毫秒也抹不掉(这里是无条件重读)。
94
- const store = this.readFromDisk();
95
- store[key] = value;
96
- try {
97
- atomicWrite(this.file, JSON.stringify(store));
98
- }
99
- catch (err) {
100
- console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
101
- }
131
+ // 与宿主 saveSettingsValues 同一把按文件路径的 RMW 锁(见 withFileRmwLock):
132
+ // 两段「读-改-写」互斥,谁也不会拿旧快照抹掉对方刚写的键。当前关键区是同步的
133
+ // (空链直跑),set() 后同步 get() 立即可见,行为与未加锁一致。
134
+ void withFileRmwLock(this.file, () => {
135
+ // 写前必重读磁盘:宿主 settings 面板的 saveSettingsValues 直写同一文件的
136
+ // settings 键(不经过本缓存),拿旧快照整份回写会把它抹掉。写是低频路径,
137
+ // 重读的代价可忽略;mtime 粒度即使同毫秒也抹不掉(这里是无条件重读)。
138
+ const store = this.readFromDisk();
139
+ store[key] = value;
140
+ try {
141
+ atomicWrite(this.file, JSON.stringify(store));
142
+ }
143
+ catch (err) {
144
+ console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
145
+ }
146
+ });
102
147
  }
103
148
  delete(key) {
104
- const store = this.readFromDisk(); // 同 set:写前重读,不拿旧快照回写
105
- if (!(key in store))
106
- return;
107
- delete store[key];
108
- try {
109
- atomicWrite(this.file, JSON.stringify(store));
110
- }
111
- catch (err) {
112
- console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
113
- }
149
+ // 同 set:写前重读 + RMW 锁,不拿旧快照回写。
150
+ void withFileRmwLock(this.file, () => {
151
+ const store = this.readFromDisk();
152
+ if (!(key in store))
153
+ return;
154
+ delete store[key];
155
+ try {
156
+ atomicWrite(this.file, JSON.stringify(store));
157
+ }
158
+ catch (err) {
159
+ console.error(`[plugin-storage] 写入失败 (${this.file}):`, err);
160
+ }
161
+ });
114
162
  }
115
163
  }
116
164
  function seal(key, plaintext) {
@@ -362,6 +410,24 @@ export class WorkspaceFS {
362
410
  }
363
411
  return target;
364
412
  }
413
+ /**
414
+ * 写类操作(write/append/mkdir/remove)的 realpath 复核:abs() 是纯字符串
415
+ * 比较,工作区里的符号链接/junction 能把写入(或递归删除)引到工作区之外。
416
+ * 取目标最近已存在祖先的 realpath,复核它仍在「活根」的 realpath 内。
417
+ * 读/list/stat/glob 保持字符串校验(高频路径;读不存在「把内容写到别处」
418
+ * 的风险,性能优先 —— 有意的取舍)。
419
+ */
420
+ assertRealInsideRoot(rel) {
421
+ const target = this.abs(rel); // 字符串越界先拒绝(错误文案不变)
422
+ const targetReal = realPathOfNearest(target);
423
+ if (!targetReal)
424
+ throw new Error(`路径越界:无法解析真实路径 ${String(rel)}`);
425
+ const rootDir = resolve(this.root());
426
+ const rootReal = realPathOfNearest(rootDir) ?? rootDir;
427
+ if (!isInsideRoot(rootReal, targetReal)) {
428
+ throw new Error(`路径越界(符号链接指向工作区之外):${String(rel)}`);
429
+ }
430
+ }
365
431
  /** 单层目录列表(浅层;深度遍历请插件自行递归)。 */
366
432
  async list(relDir = "") {
367
433
  try {
@@ -386,18 +452,21 @@ export class WorkspaceFS {
386
452
  }
387
453
  /** 写文件(自动补父目录;注意相对路径锚定当前项目——切换 cwd 后写进新项目)。 */
388
454
  async write(relPath, data) {
455
+ this.assertRealInsideRoot(relPath);
389
456
  const target = this.abs(relPath);
390
457
  await fspMkdir(dirname(target), { recursive: true });
391
458
  await fspWriteFile(target, data);
392
459
  }
393
460
  /** 追加写文件(日志/队列场景;父目录自动补;越界拒绝与 write 同口径)。 */
394
461
  async append(relPath, data) {
462
+ this.assertRealInsideRoot(relPath);
395
463
  const target = this.abs(relPath);
396
464
  await fspMkdir(dirname(target), { recursive: true });
397
465
  await fspAppendFile(target, data);
398
466
  }
399
467
  /** 建目录(递归;已存在幂等成功;越界拒绝与 write 同口径)。 */
400
468
  async mkdir(relDir) {
469
+ this.assertRealInsideRoot(relDir);
401
470
  await fspMkdir(this.abs(relDir), { recursive: true });
402
471
  }
403
472
  /** 文件元信息(size/mtime 供同步/缓存判断;不存在抛错)。 */
@@ -457,6 +526,8 @@ export class WorkspaceFS {
457
526
  }
458
527
  /** 删除文件/目录(递归;只允许删工作区内的路径)。 */
459
528
  async remove(relPath) {
529
+ // remove 同样做 realpath 复核:递归删除跟着目录链接走,比写文件更危险。
530
+ this.assertRealInsideRoot(relPath);
460
531
  await fspRm(this.abs(relPath), { recursive: true, force: false });
461
532
  }
462
533
  }