@cjhyy/code-shell-server 0.9.7 → 0.9.9

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 (109) hide show
  1. package/README.md +85 -23
  2. package/dist/bin/code-shell-project-runtime.d.ts +2 -0
  3. package/dist/bin/code-shell-project-runtime.js +8 -0
  4. package/dist/desktop-web/http-api.d.ts +43 -0
  5. package/dist/desktop-web/http-api.js +376 -0
  6. package/dist/hub/approval-lease.d.ts +30 -0
  7. package/dist/hub/approval-lease.js +89 -0
  8. package/dist/hub/auth-http.d.ts +23 -0
  9. package/dist/hub/auth-http.js +259 -0
  10. package/dist/hub/auth-store.d.ts +52 -0
  11. package/dist/hub/auth-store.js +316 -0
  12. package/dist/hub/configuration.d.ts +64 -0
  13. package/dist/hub/configuration.js +555 -0
  14. package/dist/hub/files.d.ts +9 -0
  15. package/dist/hub/files.js +278 -0
  16. package/dist/hub/mcp-configuration.d.ts +51 -0
  17. package/dist/hub/mcp-configuration.js +558 -0
  18. package/dist/hub/mcp-probe.d.ts +24 -0
  19. package/dist/hub/mcp-probe.js +392 -0
  20. package/dist/hub/model-probe.d.ts +20 -0
  21. package/dist/hub/model-probe.js +193 -0
  22. package/dist/hub/outbound-transport.d.ts +36 -0
  23. package/dist/hub/outbound-transport.js +122 -0
  24. package/dist/hub/run-replay.d.ts +42 -0
  25. package/dist/hub/run-replay.js +89 -0
  26. package/dist/hub/session-management.d.ts +36 -0
  27. package/dist/hub/session-management.js +405 -0
  28. package/dist/hub/skills-management.d.ts +43 -0
  29. package/dist/hub/skills-management.js +487 -0
  30. package/dist/hub/uploads.d.ts +22 -0
  31. package/dist/hub/uploads.js +241 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.desktop-web.d.ts +2 -0
  34. package/dist/index.desktop-web.js +2 -0
  35. package/dist/index.js +3 -0
  36. package/dist/index.links.d.ts +6 -0
  37. package/dist/index.links.js +6 -0
  38. package/dist/index.panels.d.ts +9 -0
  39. package/dist/index.panels.js +8 -0
  40. package/dist/index.serve.d.ts +1 -0
  41. package/dist/index.serve.js +1 -0
  42. package/dist/index.storage.d.ts +2 -0
  43. package/dist/index.storage.js +2 -0
  44. package/dist/links/catalog.d.ts +10 -0
  45. package/dist/links/catalog.js +41 -0
  46. package/dist/links/device-oauth.d.ts +52 -0
  47. package/dist/links/device-oauth.js +255 -0
  48. package/dist/links/http.d.ts +34 -0
  49. package/dist/links/http.js +144 -0
  50. package/dist/links/service.d.ts +54 -0
  51. package/dist/links/service.js +578 -0
  52. package/dist/links/types.d.ts +6 -0
  53. package/dist/links/types.js +1 -0
  54. package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
  55. package/dist/mobile-remote/pending-approvals.js +2 -0
  56. package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
  57. package/dist/mobile-remote/remote-host-manager.js +90 -15
  58. package/dist/mobile-remote/room-manager.js +17 -5
  59. package/dist/panels/agent-task-host.d.ts +32 -0
  60. package/dist/panels/agent-task-host.js +457 -0
  61. package/dist/panels/agent-task-models.d.ts +27 -0
  62. package/dist/panels/agent-task-models.js +51 -0
  63. package/dist/panels/agent-task-service.d.ts +105 -0
  64. package/dist/panels/agent-task-service.js +283 -0
  65. package/dist/panels/http.d.ts +34 -0
  66. package/dist/panels/http.js +37 -0
  67. package/dist/panels/hub-binding.d.ts +12 -0
  68. package/dist/panels/hub-binding.js +174 -0
  69. package/dist/panels/management-http.d.ts +29 -0
  70. package/dist/panels/management-http.js +119 -0
  71. package/dist/panels/management.d.ts +39 -0
  72. package/dist/panels/management.js +494 -0
  73. package/dist/panels/process-files.d.ts +14 -0
  74. package/dist/panels/process-files.js +299 -0
  75. package/dist/panels/process-service.d.ts +96 -0
  76. package/dist/panels/process-service.js +501 -0
  77. package/dist/panels/runtime-services.d.ts +24 -0
  78. package/dist/panels/runtime-services.js +462 -0
  79. package/dist/panels/runtime.d.ts +51 -0
  80. package/dist/panels/runtime.js +1063 -0
  81. package/dist/panels/storage-store.d.ts +9 -0
  82. package/dist/panels/storage-store.js +154 -0
  83. package/dist/panels/types.d.ts +52 -0
  84. package/dist/panels/types.js +1 -0
  85. package/dist/project-runtime/control-server.d.ts +19 -0
  86. package/dist/project-runtime/control-server.js +300 -0
  87. package/dist/project-runtime/docker-provider.d.ts +33 -0
  88. package/dist/project-runtime/docker-provider.js +487 -0
  89. package/dist/project-runtime/lock.d.ts +6 -0
  90. package/dist/project-runtime/lock.js +43 -0
  91. package/dist/project-runtime/managed-entry.d.ts +10 -0
  92. package/dist/project-runtime/managed-entry.js +113 -0
  93. package/dist/project-runtime/manager.d.ts +40 -0
  94. package/dist/project-runtime/manager.js +249 -0
  95. package/dist/project-runtime/proxy.d.ts +22 -0
  96. package/dist/project-runtime/proxy.js +706 -0
  97. package/dist/project-runtime/registry.d.ts +54 -0
  98. package/dist/project-runtime/registry.js +286 -0
  99. package/dist/project-runtime/types.d.ts +43 -0
  100. package/dist/project-runtime/types.js +1 -0
  101. package/dist/serve/cli.d.ts +5 -0
  102. package/dist/serve/cli.js +82 -23
  103. package/dist/serve/headless-server.d.ts +11 -0
  104. package/dist/serve/headless-server.js +737 -83
  105. package/dist/session-titles-store.d.ts +17 -0
  106. package/dist/session-titles-store.js +174 -0
  107. package/dist/static-files.js +1 -0
  108. package/dist/worker-bridge-core.js +17 -6
  109. package/package.json +19 -5
@@ -0,0 +1,1063 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, opendir, realpath } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, extname, join, resolve, sep } from "node:path";
6
+ import { listInstalledPanelApps, validateToolArgsStrict, } from "@cjhyy/code-shell-core";
7
+ import { PanelRuntimeServices } from "./runtime-services.js";
8
+ import { handlePanelProcessDirectory } from "./process-files.js";
9
+ import { PanelAppProcessService, panelExecutableDirectories, panelProcessInfo, } from "./process-service.js";
10
+ import { PanelManagementError } from "./management.js";
11
+ const ASSETS = "/api/v1/panel-assets/";
12
+ const ROOT = "/api/v1/panels/runtime/";
13
+ const MAX_FILE = 16 * 1024 * 1024;
14
+ const GRANT_TTL = 30 * 60_000;
15
+ const METHODS = [
16
+ "context.get",
17
+ "storage.get",
18
+ "storage.set",
19
+ "storage.delete",
20
+ "workspace.info",
21
+ "workspace.list",
22
+ "workspace.readText",
23
+ "workspace.writeText",
24
+ "external.open",
25
+ "agent.submitPrompt",
26
+ "notifications.send",
27
+ "process.find",
28
+ "process.info",
29
+ "process.spawn",
30
+ "process.cancel",
31
+ "filesystem.getKnownDirectory",
32
+ "filesystem.pickDirectory",
33
+ "filesystem.openDirectory",
34
+ "agent.task.models",
35
+ "agent.task.start",
36
+ "agent.task.list",
37
+ "agent.task.get",
38
+ "agent.task.cancel",
39
+ "tools.register",
40
+ "tools.unregister",
41
+ ];
42
+ const PERMISSIONS = new Set([
43
+ "context.session",
44
+ "context.workspace",
45
+ "storage",
46
+ "workspace.info",
47
+ "workspace.read",
48
+ "workspace.write",
49
+ "external.open",
50
+ "agent.submitPrompt",
51
+ "notifications.send",
52
+ "process",
53
+ "agent.task",
54
+ ]);
55
+ const MIME = {
56
+ ".html": "text/html; charset=utf-8",
57
+ ".js": "text/javascript; charset=utf-8",
58
+ ".mjs": "text/javascript; charset=utf-8",
59
+ ".css": "text/css; charset=utf-8",
60
+ ".json": "application/json",
61
+ ".md": "text/markdown; charset=utf-8",
62
+ ".png": "image/png",
63
+ ".jpg": "image/jpeg",
64
+ ".jpeg": "image/jpeg",
65
+ ".webp": "image/webp",
66
+ ".mp3": "audio/mpeg",
67
+ ".wav": "audio/wav",
68
+ ".woff": "font/woff",
69
+ ".woff2": "font/woff2",
70
+ ".ttf": "font/ttf",
71
+ };
72
+ export const panelWebCompatibility = (app) => ({
73
+ supported: true,
74
+ reasons: [
75
+ ...app.permissions
76
+ .filter((permission) => !PERMISSIONS.has(permission))
77
+ .map((permission) => "网页暂不提供 " + permission + ",对应功能需要桌面客户端。"),
78
+ ],
79
+ });
80
+ function methodPermission(method) {
81
+ if (method.startsWith("storage."))
82
+ return "storage";
83
+ if (method.startsWith("process.") || method.startsWith("filesystem."))
84
+ return "process";
85
+ if (method.startsWith("agent.task."))
86
+ return "agent.task";
87
+ if (method === "workspace.info")
88
+ return "workspace.info";
89
+ if (method === "workspace.writeText")
90
+ return "workspace.write";
91
+ return method.startsWith("workspace.") ? "workspace.read" : method;
92
+ }
93
+ function error(status, message) {
94
+ throw new PanelManagementError(status, "panel_runtime", message);
95
+ }
96
+ function digest(value) {
97
+ return createHash("sha256").update(value).digest("hex");
98
+ }
99
+ function json(response, status, value) {
100
+ if (response.destroyed || response.writableEnded)
101
+ return;
102
+ response.writeHead(status, {
103
+ "Content-Type": "application/json; charset=utf-8",
104
+ "Cache-Control": "no-store",
105
+ "X-Content-Type-Options": "nosniff",
106
+ });
107
+ response.end(JSON.stringify(value));
108
+ }
109
+ async function body(request) {
110
+ if ((request.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase() !==
111
+ "application/json")
112
+ error(400, "请发送 JSON 请求。");
113
+ const chunks = [];
114
+ let size = 0;
115
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
116
+ size += Buffer.byteLength(chunk);
117
+ if (size > 3 * 1024 * 1024) {
118
+ request.resume();
119
+ error(413, "面板请求内容过大。");
120
+ }
121
+ chunks.push(Buffer.from(chunk));
122
+ }
123
+ try {
124
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
125
+ if (value && typeof value === "object" && !Array.isArray(value))
126
+ return value;
127
+ }
128
+ catch {
129
+ /* report below */
130
+ }
131
+ return error(400, "面板请求格式无效。");
132
+ }
133
+ async function readAsset(root, path) {
134
+ if (!path ||
135
+ path.length > 2048 ||
136
+ path
137
+ .split("/")
138
+ .some((part) => !part || part.startsWith(".") || /[\\:\u0000-\u001f\u007f]/.test(part)) ||
139
+ !MIME[extname(path).toLowerCase()])
140
+ error(404, "找不到面板资源。");
141
+ const file = resolve(root, path);
142
+ if (!file.startsWith(root + sep))
143
+ error(403, "面板资源路径无效。");
144
+ const held = [];
145
+ try {
146
+ let current = root;
147
+ const parts = path.split("/");
148
+ for (let index = -1; index < parts.length; index++) {
149
+ if (index >= 0)
150
+ current = join(current, parts[index]);
151
+ const parent = held.at(-1);
152
+ const location = process.platform === "linux" && parent
153
+ ? `/proc/self/fd/${parent.handle.fd}/${parts[index]}`
154
+ : current;
155
+ const directory = index < parts.length - 1;
156
+ const handle = await open(location, constants.O_RDONLY |
157
+ (constants.O_NOFOLLOW ?? 0) |
158
+ (constants.O_NONBLOCK ?? 0) |
159
+ (directory ? (constants.O_DIRECTORY ?? 0) : 0));
160
+ const info = await handle.stat().catch(async (cause) => {
161
+ await handle.close();
162
+ throw cause;
163
+ });
164
+ held.push({ handle, path: current, dev: info.dev, ino: info.ino });
165
+ if (directory ? !info.isDirectory() : !info.isFile())
166
+ error(403, "面板资源必须是普通文件。");
167
+ }
168
+ const handle = held.at(-1).handle;
169
+ const info = await handle.stat();
170
+ if (!info.isFile() || info.size > MAX_FILE)
171
+ error(413, "面板资源过大。");
172
+ const chunks = [];
173
+ let total = 0;
174
+ for (;;) {
175
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_FILE + 1 - total));
176
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
177
+ if (!bytesRead)
178
+ break;
179
+ total += bytesRead;
180
+ if (total > MAX_FILE)
181
+ error(413, "面板资源过大。");
182
+ chunks.push(chunk.subarray(0, bytesRead));
183
+ }
184
+ for (const entry of held) {
185
+ const actual = await lstat(entry.path);
186
+ if (actual.isSymbolicLink() || actual.dev !== entry.dev || actual.ino !== entry.ino)
187
+ error(409, "面板资源路径已经变化,请重新打开。");
188
+ }
189
+ return Buffer.concat(chunks, total);
190
+ }
191
+ finally {
192
+ await Promise.all(held.map(({ handle }) => handle.close().catch(() => { })));
193
+ }
194
+ }
195
+ async function snapshotFiles(root, directory) {
196
+ const files = new Map();
197
+ let total = 0;
198
+ let count = 0;
199
+ async function walk(path, depth) {
200
+ if (depth > 16)
201
+ error(413, "面板目录过深。");
202
+ const metadata = await lstat(join(root, path));
203
+ if (metadata.isSymbolicLink() || !metadata.isDirectory())
204
+ error(403, "面板目录不能是符号链接。");
205
+ const folder = await opendir(join(root, path));
206
+ for await (const entry of folder) {
207
+ if (++count > 2000 || entry.isSymbolicLink() || entry.name.startsWith("."))
208
+ error(403, "面板文件结构不安全。");
209
+ const child = path + "/" + entry.name;
210
+ if (entry.isDirectory())
211
+ await walk(child, depth + 1);
212
+ else {
213
+ const bytes = await readAsset(root, child);
214
+ total += bytes.length;
215
+ if (total > 64 * 1024 * 1024)
216
+ error(413, "面板资源过大。");
217
+ files.set(child, digest(bytes));
218
+ }
219
+ }
220
+ }
221
+ await walk(directory, 0);
222
+ return files;
223
+ }
224
+ /** Browser compatibility shim. It receives no login cookie, filesystem path or API credential. */
225
+ function bridgeScript(id, origin) {
226
+ return `(() => {
227
+ "use strict";
228
+ const id = ${JSON.stringify(id)}, origin = ${JSON.stringify(origin)};
229
+ let next = 0;
230
+ const pending = new Map(), listeners = new Map(), tools = new Map();
231
+ const ask = (method, params) => new Promise((resolve, reject) => {
232
+ if (typeof method !== "string" || method.length > 64) {
233
+ reject(new Error("Invalid panel method")); return;
234
+ }
235
+ if (pending.size >= 64) {
236
+ reject(new Error("Too many pending panel calls")); return;
237
+ }
238
+ const requestId = String(++next);
239
+ const timer = setTimeout(() => {
240
+ pending.delete(requestId); reject(new Error("Panel request timed out"));
241
+ }, 60000);
242
+ pending.set(requestId, { resolve, reject, timer });
243
+ try {
244
+ parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
245
+ } catch (error) {
246
+ clearTimeout(timer); pending.delete(requestId); reject(error);
247
+ }
248
+ });
249
+ addEventListener("message", event => {
250
+ if (event.source !== parent || event.origin !== origin || event.data?.instanceId !== id) return;
251
+ const data = event.data;
252
+ if (data.type === "codeshell-panel:response") {
253
+ const item = pending.get(data.requestId);
254
+ if (!item) return;
255
+ pending.delete(data.requestId); clearTimeout(item.timer);
256
+ data.error ? item.reject(new Error(String(data.error))) : item.resolve(data.result);
257
+ } else if (data.type === "codeshell-panel:event") {
258
+ if (data.event === "tools.invoke") {
259
+ const call = data.payload;
260
+ const handler = tools.get(call?.toolName);
261
+ Promise.resolve().then(() => {
262
+ if (!handler) throw new Error("Panel tool is not registered");
263
+ return handler(call.args);
264
+ }).then(result => parent.postMessage({ type: "codeshell-panel:tool-result", instanceId: id, requestId: call.requestId, result }, origin),
265
+ error => parent.postMessage({ type: "codeshell-panel:tool-result", instanceId: id, requestId: call.requestId, error: String(error?.message || error).slice(0, 1000) }, origin));
266
+ return;
267
+ }
268
+ for (const listener of listeners.get(data.event) || []) {
269
+ try { listener(data.payload); } catch {}
270
+ }
271
+ }
272
+ });
273
+ addEventListener("pagehide", () => {
274
+ for (const item of pending.values()) {
275
+ clearTimeout(item.timer); item.reject(new Error("Panel closed"));
276
+ }
277
+ pending.clear(); listeners.clear(); tools.clear();
278
+ }, { once: true });
279
+ Object.defineProperty(window, "codeshellPanel", {
280
+ value: Object.freeze({
281
+ getContext: () => ask("context.get"),
282
+ call: ask,
283
+ on: (name, listener) => {
284
+ if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
285
+ throw new Error("Invalid event listener");
286
+ }
287
+ let list = listeners.get(name);
288
+ if (!list) listeners.set(name, list = new Set());
289
+ list.add(listener);
290
+ return () => list.delete(listener);
291
+ },
292
+ registerTool: (name, handler) => {
293
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(name) || typeof handler !== "function") throw new Error("Invalid tool");
294
+ if (tools.has(name)) throw new Error("Tool already registered");
295
+ if (tools.size >= 16) throw new Error("Too many panel tools");
296
+ tools.set(name, handler);
297
+ void ask("tools.register", { name }).catch(() => tools.delete(name));
298
+ return () => { tools.delete(name); void ask("tools.unregister", { name }).catch(() => {}); };
299
+ }
300
+ }),
301
+ writable: false, configurable: false
302
+ });
303
+ parent.postMessage({ type: "codeshell-panel:ready", instanceId: id }, origin);
304
+ })();`;
305
+ }
306
+ function parentOrigin(request) {
307
+ // The integrating auth facade validates Origin against its bound/public URL.
308
+ // Origin-less native clients use the transport host; asset requests never
309
+ // control the parent origin or the CSP of an already prepared grant.
310
+ const supplied = request.headers.origin;
311
+ const raw = supplied ??
312
+ `${"encrypted" in request.socket && request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
313
+ try {
314
+ const url = new URL(raw);
315
+ if (!["http:", "https:"].includes(url.protocol) ||
316
+ url.username ||
317
+ url.password ||
318
+ url.pathname !== "/" ||
319
+ url.search ||
320
+ url.hash ||
321
+ (supplied !== undefined && supplied !== url.origin))
322
+ error(400, "面板来源无效。");
323
+ return url.origin;
324
+ }
325
+ catch {
326
+ return error(400, "面板来源无效。");
327
+ }
328
+ }
329
+ function injectBridge(html, source) {
330
+ const script = '<script src="' + source + '_codeshell_bridge.js"></script>';
331
+ const head = /<head(?:\s[^>]*)?>/i.exec(html);
332
+ const firstScript = /<script(?:\s|>)/i.exec(html);
333
+ if (head && (!firstScript || head.index < firstScript.index))
334
+ return (html.slice(0, head.index + head[0].length) + script + html.slice(head.index + head[0].length));
335
+ const element = /<html(?:\s[^>]*)?>/i.exec(html);
336
+ if (element && (!firstScript || element.index < firstScript.index))
337
+ return (html.slice(0, element.index + element[0].length) +
338
+ "<head>" +
339
+ script +
340
+ "</head>" +
341
+ html.slice(element.index + element[0].length));
342
+ const doctype = /^(?:\uFEFF)?\s*<!doctype[^>]*>/i.exec(html);
343
+ const offset = doctype?.[0].length ?? 0;
344
+ return html.slice(0, offset) + script + html.slice(offset);
345
+ }
346
+ /** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
347
+ export function createPanelRuntime(options) {
348
+ const publicPathPrefix = options.publicPathPrefix ?? "";
349
+ if (publicPathPrefix &&
350
+ !/^\/p\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(publicPathPrefix))
351
+ throw new Error("Invalid project public path prefix");
352
+ const grants = new Map();
353
+ const assets = new Map();
354
+ const now = options.now ?? Date.now;
355
+ const services = new PanelRuntimeServices({ dataDir: options.dataDir });
356
+ const installed = options.listInstalled ?? listInstalledPanelApps;
357
+ let closed = false;
358
+ let generation = 0;
359
+ let nextGuest = 0;
360
+ const byGuest = new Map();
361
+ const processes = new PanelAppProcessService({
362
+ approvalScope: "guest",
363
+ extraPathDirectories: () => panelExecutableDirectories(join(options.dataDir, "panel-bin"), { home: homedir() }),
364
+ isOwnerAuthorized: async (owner) => {
365
+ const grant = byGuest.get(owner.guestId);
366
+ return !!grant && (await authorized(grant));
367
+ },
368
+ confirmExecution: async (input) => {
369
+ const grant = byGuest.get(input.guestId);
370
+ return (!!grant &&
371
+ (await confirm(grant, `允许 ${input.appTitle} 运行 ${input.executable}?`, `程序将在服务端运行,可使用当前运行用户的文件与网络权限。\n程序:${input.executablePath}`)));
372
+ },
373
+ });
374
+ const agentTasks = options.createAgentTasks?.({ onPanelAction }) ?? options.agentTasks;
375
+ const reaper = setInterval(() => {
376
+ for (const grant of grants.values())
377
+ void authorized(grant).catch(() => remove(grant));
378
+ }, 60_000);
379
+ reaper.unref();
380
+ const preparing = new Set();
381
+ let readingSnapshot;
382
+ function snapshot() {
383
+ // Parallel module requests share current disk work, without a stale TTL
384
+ // that could keep an unbound or updated package authorized.
385
+ const pending = readingSnapshot ?? options.snapshot();
386
+ readingSnapshot = pending;
387
+ void pending
388
+ .finally(() => {
389
+ if (readingSnapshot === pending)
390
+ readingSnapshot = undefined;
391
+ })
392
+ .catch(() => { });
393
+ return pending;
394
+ }
395
+ function remove(grant) {
396
+ if (grants.get(grant.id) !== grant)
397
+ return;
398
+ grants.delete(grant.id);
399
+ assets.delete(grant.asset);
400
+ byGuest.delete(grant.guestId);
401
+ processes.revokeGuest(grant.guestId);
402
+ agentTasks?.revokeInstance(grant.id);
403
+ for (const item of grant.confirmations.values())
404
+ item.finish(false);
405
+ for (const item of grant.toolCalls.values())
406
+ item.finish({ error: "面板已经关闭或授权已撤销。" });
407
+ grant.events.length = 0;
408
+ }
409
+ function emit(grant, event, payload) {
410
+ if (grants.get(grant.id) !== grant)
411
+ return;
412
+ const bytes = Buffer.byteLength(JSON.stringify(payload) ?? "null");
413
+ if (bytes > 512 * 1024 ||
414
+ grant.events.length >= 1024 ||
415
+ grant.eventBytes + bytes > 5 * 1024 * 1024) {
416
+ remove(grant);
417
+ return;
418
+ }
419
+ grant.eventBytes += bytes;
420
+ grant.events.push({ id: ++grant.eventCounter, event, payload });
421
+ }
422
+ async function confirm(grant, title, message) {
423
+ if (grant.confirmations.size >= 48) {
424
+ for (const [id, value] of grant.confirmations) {
425
+ if (value.result !== undefined)
426
+ grant.confirmations.delete(id);
427
+ if (grant.confirmations.size < 32)
428
+ break;
429
+ }
430
+ }
431
+ if (!(await authorized(grant)) || grant.confirmations.size >= 64)
432
+ return false;
433
+ const requestId = randomBytes(18).toString("base64url");
434
+ const result = await new Promise((done) => {
435
+ const timer = setTimeout(() => finish(false), 50_000);
436
+ timer.unref();
437
+ let settled = false;
438
+ const finish = (allowed) => {
439
+ if (settled)
440
+ return;
441
+ settled = true;
442
+ clearTimeout(timer);
443
+ const entry = grant.confirmations.get(requestId);
444
+ if (entry)
445
+ entry.result = allowed;
446
+ done(allowed);
447
+ };
448
+ grant.confirmations.set(requestId, { finish });
449
+ emit(grant, "host.confirm", {
450
+ requestId,
451
+ title: title.slice(0, 180),
452
+ body: message.slice(0, 8000),
453
+ });
454
+ });
455
+ return result && (await authorized(grant));
456
+ }
457
+ function taskScope(grant) {
458
+ return {
459
+ instanceId: grant.id,
460
+ ownerId: grant.owner,
461
+ appId: grant.app.id,
462
+ appTitle: grant.app.title.default,
463
+ projectPath: options.bindingCwd ?? options.cwd,
464
+ cwd: options.cwd,
465
+ permissions: grant.app.permissions,
466
+ availableSkills: (grant.app.agent?.skills ?? []).flatMap((path) => {
467
+ const match = /^agent\/skills\/([a-z][a-z0-9-]{0,63})\/SKILL\.md$/.exec(path);
468
+ return match ? [`${grant.app.id}:${match[1]}`] : [];
469
+ }),
470
+ isAuthorized: () => authorized(grant),
471
+ emit: (event, payload) => {
472
+ if (event === "agent.task.approvalRequested") {
473
+ const input = payload;
474
+ void confirm(grant, String(input.title ?? "允许面板 AI 任务执行此操作?"), String(input.body ?? JSON.stringify(input.request ?? input)))
475
+ .then((approved) => agentTasks?.call(taskScope(grant), "agent.task.approvalRespond", {
476
+ taskId: input.taskId,
477
+ requestId: input.requestId,
478
+ approved,
479
+ }))
480
+ .catch(() => { });
481
+ }
482
+ else
483
+ emit(grant, event, payload);
484
+ },
485
+ };
486
+ }
487
+ async function onPanelAction(scope, input) {
488
+ const grant = grants.get(scope.instanceId);
489
+ if (!grant || grant.owner !== scope.ownerId || !(await authorized(grant)))
490
+ return { ok: false, detail: "面板已关闭或授权已撤销。" };
491
+ const panelId = `panel-app:${grant.app.id}`;
492
+ if (input.action === "list")
493
+ return {
494
+ ok: true,
495
+ panels: [{ id: panelId, title: grant.app.title.default, source: "panel-app" }],
496
+ };
497
+ if (input.panelId !== panelId && input.panelId !== grant.app.id)
498
+ return { ok: false, detail: "这个任务只能操作发起它的面板。" };
499
+ if (input.action === "open")
500
+ return { ok: true, panelId: input.panelId };
501
+ const descriptors = (grant.app.agent?.tools ?? []).filter((tool) => grant.registeredTools.has(tool.name));
502
+ if (input.action === "tools")
503
+ return { ok: true, tools: descriptors };
504
+ const tool = descriptors.find((entry) => entry.name === input.toolName);
505
+ if (input.action !== "invoke" || !tool)
506
+ return { ok: false, detail: "这个面板工具尚未注册。" };
507
+ const args = input.arguments ?? {};
508
+ if (!args ||
509
+ typeof args !== "object" ||
510
+ Array.isArray(args) ||
511
+ Buffer.byteLength(JSON.stringify(args)) > 128 * 1024)
512
+ return { ok: false, detail: "面板工具参数格式无效或过大。" };
513
+ const validation = validateToolArgsStrict(tool.name, args, tool.inputSchema);
514
+ if (validation)
515
+ return { ok: false, detail: validation };
516
+ if (!tool.readOnly &&
517
+ !(await confirm(grant, `允许面板工具 ${tool.name} 修改内容?`, JSON.stringify(args).slice(0, 8000))))
518
+ return { ok: false, detail: "用户取消了面板工具操作。" };
519
+ if (grant.toolCalls.size >= 16)
520
+ return { ok: false, detail: "面板工具调用过多。" };
521
+ const requestId = randomBytes(18).toString("base64url");
522
+ const value = await new Promise((done) => {
523
+ const timer = setTimeout(() => finish({ error: "面板工具调用超时。" }), 45_000);
524
+ timer.unref();
525
+ const finish = (result) => {
526
+ if (!grant.toolCalls.delete(requestId))
527
+ return;
528
+ clearTimeout(timer);
529
+ done(result);
530
+ };
531
+ grant.toolCalls.set(requestId, { finish });
532
+ emit(grant, "tools.invoke", { requestId, toolName: tool.name, args });
533
+ });
534
+ if (!(await authorized(grant)))
535
+ return { ok: false, detail: "面板授权已撤销。" };
536
+ return {
537
+ ok: !value.error,
538
+ panelId: input.panelId,
539
+ toolName: tool.name,
540
+ ...(value.error ? { detail: value.error } : { result: value.result }),
541
+ };
542
+ }
543
+ function cancelOwner(owner) {
544
+ for (const preparation of preparing)
545
+ if (preparation.owner === owner)
546
+ preparation.cancelled = true;
547
+ for (const grant of grants.values())
548
+ if (grant.owner === owner)
549
+ remove(grant);
550
+ }
551
+ async function authorized(grant) {
552
+ if (closed ||
553
+ grants.get(grant.id) !== grant ||
554
+ grant.expiresAt <= now() ||
555
+ !(await options.isAuthorized(grant.request)) ||
556
+ (await options.ownerId(grant.request)) !== grant.owner) {
557
+ remove(grant);
558
+ return false;
559
+ }
560
+ try {
561
+ const panel = (await snapshot()).panels.find((candidate) => candidate.id === grant.app.id);
562
+ const info = await lstat(grant.root);
563
+ const ownerStillAuthorized = await options.isAuthorized(grant.request);
564
+ const currentOwner = await options.ownerId(grant.request);
565
+ const valid = !closed &&
566
+ grants.get(grant.id) === grant &&
567
+ grant.expiresAt > now() &&
568
+ ownerStillAuthorized &&
569
+ currentOwner === grant.owner &&
570
+ !!panel?.enabled &&
571
+ panel.revision === grant.revision &&
572
+ !info.isSymbolicLink() &&
573
+ info.isDirectory() &&
574
+ info.dev + ":" + info.ino === grant.rootIdentity;
575
+ if (!valid)
576
+ remove(grant);
577
+ return valid;
578
+ }
579
+ catch {
580
+ remove(grant);
581
+ return false;
582
+ }
583
+ }
584
+ async function prepare(request, input) {
585
+ const owner = await options.ownerId(request);
586
+ if (!owner || !(await options.isAuthorized(request)))
587
+ error(401, "请先登录。");
588
+ if (closed)
589
+ error(410, "面板服务已关闭,请重新打开工作台。");
590
+ if (preparing.size >= 4 || [...preparing].filter((item) => item.owner === owner).length >= 2)
591
+ error(429, "正在打开的面板过多,请稍后重试。");
592
+ const preparation = { owner, cancelled: false };
593
+ preparing.add(preparation);
594
+ try {
595
+ const startedGeneration = generation;
596
+ const origin = parentOrigin(request);
597
+ if (typeof input.appId !== "string" ||
598
+ !/^[a-z][a-z0-9-]{0,63}$/.test(input.appId) ||
599
+ typeof input.revision !== "string" ||
600
+ !/^[a-f0-9]{64}$/.test(input.revision) ||
601
+ Object.keys(input).some((key) => !["appId", "revision", "sessionId", "theme", "locale"].includes(key)))
602
+ error(400, "面板参数无效。");
603
+ const panel = (await snapshot()).panels.find((candidate) => candidate.id === input.appId);
604
+ if (!panel?.enabled || !panel.compatibility.supported)
605
+ error(403, "请先将面板绑定到当前工作区。");
606
+ if (panel.revision !== input.revision)
607
+ error(409, "面板已经更新,请重新打开。");
608
+ if (input.sessionId !== undefined &&
609
+ (typeof input.sessionId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/.test(input.sessionId)))
610
+ error(400, "会话标识无效。");
611
+ const app = (await installed()).find((candidate) => candidate.id === input.appId);
612
+ if (!app)
613
+ error(404, "面板已卸载。");
614
+ const root = await realpath(app.installPath), info = await lstat(app.installPath);
615
+ if (info.isSymbolicLink() || !info.isDirectory())
616
+ error(403, "面板安装目录无效。");
617
+ const files = await snapshotFiles(root, dirname(app.entry));
618
+ if (!files.has(app.entry))
619
+ error(404, "找不到面板入口。");
620
+ if (closed || generation !== startedGeneration || preparation.cancelled)
621
+ error(410, "面板授权已失效,请重新打开。");
622
+ for (const grant of grants.values())
623
+ if (grant.expiresAt <= now())
624
+ remove(grant);
625
+ if (grants.size >= 64 ||
626
+ [...grants.values()].filter((grant) => grant.owner === owner).length >= 16)
627
+ error(429, "打开的面板过多,请先关闭一些面板。");
628
+ const id = randomBytes(24).toString("base64url"), asset = randomBytes(32).toString("base64url");
629
+ const context = {
630
+ appId: app.id,
631
+ visible: true,
632
+ theme: input.theme === "dark" ? "dark" : "light",
633
+ locale: typeof input.locale === "string" ? input.locale.slice(0, 32) : "zh-CN",
634
+ apiVersion: agentTasks ? 9 : 7,
635
+ host: options.host,
636
+ availableMethods: METHODS.filter((method) => {
637
+ if (method.startsWith("agent.task.") && !agentTasks)
638
+ return false;
639
+ if (method.startsWith("tools."))
640
+ return !!app.agent?.tools.length;
641
+ return (method === "context.get" || app.permissions.includes(methodPermission(method)));
642
+ }),
643
+ limitations: panel.compatibility.reasons,
644
+ };
645
+ if (app.permissions.includes("context.workspace")) {
646
+ context.cwd = options.cwd;
647
+ context.trusted = true;
648
+ }
649
+ if (app.permissions.includes("context.session") && input.sessionId) {
650
+ context.sessionId = input.sessionId;
651
+ context.busy = false;
652
+ }
653
+ const grant = {
654
+ guestId: ++nextGuest,
655
+ id,
656
+ asset,
657
+ owner,
658
+ request,
659
+ origin,
660
+ app,
661
+ revision: panel.revision,
662
+ root,
663
+ rootIdentity: info.dev + ":" + info.ino,
664
+ files,
665
+ context,
666
+ expiresAt: now() + GRANT_TTL,
667
+ calls: [],
668
+ events: [],
669
+ eventCounter: 0,
670
+ eventBytes: 0,
671
+ registeredTools: new Set(),
672
+ confirmations: new Map(),
673
+ toolCalls: new Map(),
674
+ };
675
+ grants.set(id, grant);
676
+ byGuest.set(grant.guestId, grant);
677
+ assets.set(asset, grant);
678
+ if (!(await authorized(grant))) {
679
+ remove(grant);
680
+ error((await options.isAuthorized(request)) ? 410 : 401, "面板授权已失效,请重新打开。");
681
+ }
682
+ return {
683
+ instanceId: id,
684
+ src: ASSETS + asset + "/" + app.entry.split("/").map(encodeURIComponent).join("/"),
685
+ expiresAt: grant.expiresAt,
686
+ context,
687
+ limitations: panel.compatibility.reasons,
688
+ };
689
+ }
690
+ finally {
691
+ preparing.delete(preparation);
692
+ }
693
+ }
694
+ async function call(grant, input) {
695
+ if (!(await authorized(grant)))
696
+ error(410, "面板授权已失效,请重新打开。");
697
+ if (Object.keys(input).some((key) => !["method", "params"].includes(key)))
698
+ error(400, "面板调用参数无效。");
699
+ const method = input.method, params = input.params;
700
+ if (typeof method !== "string" || !METHODS.includes(method))
701
+ error(501, "这个面板功能尚未接入网页版,请在桌面客户端使用:" + String(method).slice(0, 80));
702
+ grant.calls = grant.calls.filter((time) => time > now() - 60_000);
703
+ if (grant.calls.length >= 240)
704
+ error(429, "面板请求过于频繁。");
705
+ grant.calls.push(now());
706
+ if (method === "context.get")
707
+ return grant.context;
708
+ if (method.startsWith("tools.")) {
709
+ const name = params?.name;
710
+ if (typeof name !== "string" || !grant.app.agent?.tools.some((tool) => tool.name === name))
711
+ error(403, "面板未声明这个 Agent 工具。");
712
+ if (method === "tools.register")
713
+ grant.registeredTools.add(name);
714
+ else
715
+ grant.registeredTools.delete(name);
716
+ return true;
717
+ }
718
+ const permission = methodPermission(method);
719
+ if (!grant.app.permissions.includes(permission))
720
+ error(403, "面板未声明这个权限。");
721
+ if (method.startsWith("agent.task.")) {
722
+ if (!agentTasks)
723
+ error(501, "当前宿主未提供独立面板任务。");
724
+ if (method === "agent.task.start" &&
725
+ !(await confirm(grant, `启动 ${grant.app.title.default} 的 AI 任务?`, String(params?.prompt ?? "").slice(0, 8000))))
726
+ error(403, "你取消了面板 AI 任务。");
727
+ return agentTasks.call(taskScope(grant), method, params);
728
+ }
729
+ if (method.startsWith("process.") || method.startsWith("filesystem.")) {
730
+ const owner = {
731
+ guestId: grant.guestId,
732
+ appId: grant.app.id,
733
+ appTitle: grant.app.title.default,
734
+ revision: grant.revision,
735
+ send: (event, payload) => emit(grant, event, payload),
736
+ };
737
+ if (method === "process.find")
738
+ return processes.findExecutable(owner, params);
739
+ if (method === "process.info")
740
+ return panelProcessInfo();
741
+ if (method === "process.spawn")
742
+ return processes.start(owner, params);
743
+ if (method === "process.cancel")
744
+ return processes.cancel(owner, params);
745
+ if (method === "filesystem.openDirectory") {
746
+ const handle = params?.handle;
747
+ const path = processes.directoryPath(owner, handle);
748
+ return {
749
+ effect: method,
750
+ path,
751
+ opened: false,
752
+ url: `${ROOT}${grant.id}/directory/${handle}`,
753
+ };
754
+ }
755
+ const name = method === "filesystem.pickDirectory" ? "downloads" : params?.name;
756
+ const directory = name === "downloads"
757
+ ? join(options.cwd, "downloads")
758
+ : name === "user-bin"
759
+ ? join(options.dataDir, "panel-bin")
760
+ : name === "app-data"
761
+ ? join(options.dataDir, "panel-data", grant.app.id, digest(Buffer.from(options.bindingCwd ?? options.cwd)).slice(0, 24))
762
+ : undefined;
763
+ if (!directory)
764
+ error(400, "不支持这个服务端目录。");
765
+ if (method === "filesystem.pickDirectory" &&
766
+ !(await confirm(grant, "使用服务端下载目录?", directory)))
767
+ return null;
768
+ await mkdir(directory, { recursive: true, mode: 0o700 });
769
+ if (!(await authorized(grant)))
770
+ error(410, "面板授权已失效。");
771
+ return processes.grantDirectory(owner, directory);
772
+ }
773
+ if (["external.open", "agent.submitPrompt", "notifications.send"].includes(method)) {
774
+ if (!grant.app.permissions.includes(method))
775
+ error(403, "面板未声明这个权限。");
776
+ const value = params;
777
+ if (method === "external.open") {
778
+ let url;
779
+ try {
780
+ url = new URL(String(value?.url));
781
+ }
782
+ catch {
783
+ return error(400, "链接格式无效。");
784
+ }
785
+ if (url.protocol !== "https:" || url.username || url.password || url.href.length > 2048)
786
+ error(400, "面板只能请求打开 HTTPS 链接。");
787
+ return { effect: method, url: url.href };
788
+ }
789
+ if (method === "agent.submitPrompt") {
790
+ if (!grant.context.sessionId)
791
+ error(409, "请先选择一个对话。");
792
+ if (typeof value?.prompt !== "string" ||
793
+ !value.prompt.trim() ||
794
+ value.prompt.length > 20000)
795
+ error(400, "面板任务内容无效。");
796
+ if (value.displayText !== undefined &&
797
+ (typeof value.displayText !== "string" ||
798
+ !value.displayText.trim() ||
799
+ value.displayText.length > 20000))
800
+ error(400, "面板任务显示内容无效。");
801
+ return {
802
+ effect: method,
803
+ prompt: value.prompt,
804
+ sessionId: grant.context.sessionId,
805
+ ...(value.displayText ? { displayText: value.displayText } : {}),
806
+ };
807
+ }
808
+ if (typeof value?.body !== "string" ||
809
+ !value.body.trim() ||
810
+ value.body.length > 500 ||
811
+ (value.title !== undefined &&
812
+ (typeof value.title !== "string" || !value.title.trim() || value.title.length > 80)))
813
+ error(400, "面板通知内容无效。");
814
+ return { effect: method, title: value.title ?? grant.app.title.default, body: value.body };
815
+ }
816
+ return services.call({
817
+ appId: grant.app.id,
818
+ cwd: options.cwd,
819
+ projectPath: options.bindingCwd ?? options.cwd,
820
+ permissions: grant.app.permissions,
821
+ isAuthorized: () => authorized(grant),
822
+ }, method, params);
823
+ }
824
+ return {
825
+ activeTaskCount: () => agentTasks?.activeTaskCount?.() ?? 0,
826
+ async panelAction(ownerId, sessionId, input) {
827
+ const candidates = [];
828
+ for (const grant of grants.values()) {
829
+ if (grant.owner === ownerId &&
830
+ grant.context.sessionId === sessionId &&
831
+ (await authorized(grant)))
832
+ candidates.push(grant);
833
+ }
834
+ if (input.action === "list")
835
+ return {
836
+ ok: true,
837
+ panels: candidates.map((grant) => ({
838
+ id: `panel-app:${grant.app.id}`,
839
+ title: grant.app.title.default,
840
+ source: "panel-app",
841
+ })),
842
+ };
843
+ const grant = candidates
844
+ .reverse()
845
+ .find((item) => input.panelId === item.app.id || input.panelId === `panel-app:${item.app.id}`);
846
+ if (!grant)
847
+ return { ok: false, detail: "请先在此对话中打开相应面板。" };
848
+ return onPanelAction(taskScope(grant), input);
849
+ },
850
+ ownsAssets(request) {
851
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
852
+ return (pathname.startsWith(ASSETS) && assets.has(pathname.slice(ASSETS.length).split("/", 1)[0]));
853
+ },
854
+ cancelOwner,
855
+ invalidate(appId) {
856
+ generation++;
857
+ for (const grant of grants.values())
858
+ if (!appId || grant.app.id === appId)
859
+ remove(grant);
860
+ },
861
+ close() {
862
+ closed = true;
863
+ clearInterval(reaper);
864
+ for (const grant of grants.values())
865
+ remove(grant);
866
+ processes.close();
867
+ void agentTasks?.close();
868
+ assets.clear();
869
+ for (const preparation of preparing)
870
+ preparation.cancelled = true;
871
+ },
872
+ async handle(request, response) {
873
+ const url = new URL(request.url ?? "/", "http://localhost");
874
+ if (!url.pathname.startsWith(ROOT))
875
+ return false;
876
+ let activeGrant;
877
+ try {
878
+ const currentOwner = await options.ownerId(request);
879
+ if (!currentOwner || !(await options.isAuthorized(request)))
880
+ error(401, "请先登录。");
881
+ if (url.pathname === ROOT + "prepare" && request.method === "POST") {
882
+ json(response, 200, await prepare(request, await body(request)));
883
+ return true;
884
+ }
885
+ const directoryMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/directory\/([a-f0-9-]{36})$/.exec(url.pathname);
886
+ const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|tool-results))?$/.exec(url.pathname) ?? directoryMatch;
887
+ const grant = match && grants.get(match[1]);
888
+ if (!grant)
889
+ error(410, "面板连接已失效,请关闭后重新打开。");
890
+ if (currentOwner !== grant.owner)
891
+ error(403, "这个面板属于另一个登录会话。");
892
+ activeGrant = grant;
893
+ if (!(await authorized(grant)))
894
+ error(410, "面板连接已失效,请关闭后重新打开。");
895
+ if (directoryMatch) {
896
+ if (!grant.app.permissions.includes("process"))
897
+ error(403, "面板未声明这个权限。");
898
+ const root = processes.directoryPath({
899
+ guestId: grant.guestId,
900
+ appId: grant.app.id,
901
+ appTitle: grant.app.title.default,
902
+ revision: grant.revision,
903
+ send: () => { },
904
+ }, directoryMatch[2]);
905
+ await handlePanelProcessDirectory(request, response, {
906
+ root,
907
+ baseUrl: url.pathname,
908
+ linkPrefix: publicPathPrefix,
909
+ workspace: options.cwd,
910
+ isAuthorized: () => authorized(grant),
911
+ });
912
+ }
913
+ else if (request.method === "DELETE" && !match[2]) {
914
+ remove(grant);
915
+ json(response, 200, { closed: true });
916
+ }
917
+ else if (request.method === "POST" && match[2] === "renew") {
918
+ const input = await body(request);
919
+ if (Object.keys(input).length)
920
+ error(400, "续期参数无效。");
921
+ if (!(await authorized(grant)))
922
+ error(410, "面板授权已失效。");
923
+ grant.expiresAt = now() + GRANT_TTL;
924
+ json(response, 200, { expiresAt: grant.expiresAt });
925
+ }
926
+ else if (request.method === "GET" && match[2] === "events") {
927
+ const after = Number(url.searchParams.get("after") ?? 0);
928
+ if (!Number.isSafeInteger(after) || after < 0 || after > grant.eventCounter)
929
+ error(400, "事件游标无效。");
930
+ grant.events = grant.events.filter((entry) => entry.id > after);
931
+ grant.eventBytes = grant.events.reduce((total, entry) => total + Buffer.byteLength(JSON.stringify(entry.payload) ?? "null"), 0);
932
+ const batch = grant.events.slice(0, 128);
933
+ json(response, 200, { events: batch, cursor: batch.at(-1)?.id ?? after });
934
+ }
935
+ else if (request.method === "POST" && match[2] === "confirm") {
936
+ const input = await body(request);
937
+ const pending = typeof input.requestId === "string" && grant.confirmations.get(input.requestId);
938
+ if (!pending ||
939
+ typeof input.allowed !== "boolean" ||
940
+ Object.keys(input).some((key) => !["requestId", "allowed"].includes(key)))
941
+ error(400, "找不到这个待确认操作。");
942
+ if (!(await authorized(grant)))
943
+ error(410, "面板授权已失效。");
944
+ pending.finish(input.allowed);
945
+ json(response, 200, { accepted: true, allowed: pending.result });
946
+ }
947
+ else if (request.method === "POST" && match[2] === "tool-results") {
948
+ const input = await body(request);
949
+ const pending = typeof input.requestId === "string" && grant.toolCalls.get(input.requestId);
950
+ if (!pending ||
951
+ Object.keys(input).some((key) => !["requestId", "result", "error"].includes(key)) ||
952
+ (input.error !== undefined && typeof input.error !== "string"))
953
+ error(400, "找不到这个面板工具调用。");
954
+ if (Buffer.byteLength(JSON.stringify(input)) > 512 * 1024)
955
+ error(413, "面板工具结果过大。");
956
+ if (!(await authorized(grant)))
957
+ error(410, "面板授权已失效。");
958
+ pending.finish({
959
+ result: input.result,
960
+ error: typeof input.error === "string" ? input.error.slice(0, 1000) : undefined,
961
+ });
962
+ json(response, 200, { accepted: true });
963
+ }
964
+ else if (request.method === "POST" && match[2] === "call") {
965
+ const value = await call(grant, await body(request));
966
+ if (!(await authorized(grant)))
967
+ error(410, "面板授权已失效,请重新打开。");
968
+ json(response, 200, value);
969
+ }
970
+ else
971
+ error(405, "不支持这个面板请求。");
972
+ }
973
+ catch (cause) {
974
+ let status = cause instanceof PanelManagementError ? cause.status : 400;
975
+ if (activeGrant && !(await options.isAuthorized(request)))
976
+ status = 401;
977
+ else if (activeGrant && !(await authorized(activeGrant)))
978
+ status = 410;
979
+ json(response, status, {
980
+ error: cause instanceof Error ? cause.message : "面板请求失败。",
981
+ });
982
+ }
983
+ return true;
984
+ },
985
+ async handleAssets(request, response) {
986
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
987
+ if (!pathname.startsWith(ASSETS))
988
+ return false;
989
+ const parts = pathname.slice(ASSETS.length).split("/"), grant = assets.get(parts.shift());
990
+ // Consume all asset routes, including stale grants, instead of falling
991
+ // through to the authenticated SPA or another workspace's runtime.
992
+ if (!grant) {
993
+ json(response, 404, { error: "面板资源授权不存在或已失效。" });
994
+ return true;
995
+ }
996
+ try {
997
+ if (!(await authorized(grant)))
998
+ error(410, "面板授权已失效,请重新打开。");
999
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method ?? ""))
1000
+ error(405, "面板资源只读。");
1001
+ response.setHeader("Access-Control-Allow-Origin", "*");
1002
+ response.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
1003
+ response.setHeader("Cache-Control", "no-store");
1004
+ response.setHeader("Referrer-Policy", "no-referrer");
1005
+ response.setHeader("X-Content-Type-Options", "nosniff");
1006
+ if (request.method === "OPTIONS") {
1007
+ response.writeHead(204, { "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS" });
1008
+ response.end();
1009
+ return true;
1010
+ }
1011
+ const path = parts.map(decodeURIComponent).join("/");
1012
+ let bytes;
1013
+ if (path === "_codeshell_bridge.js")
1014
+ bytes = Buffer.from(bridgeScript(grant.id, grant.origin));
1015
+ else {
1016
+ if (!grant.files.has(path))
1017
+ error(404, "找不到面板资源。");
1018
+ bytes = await readAsset(grant.root, path);
1019
+ if (digest(bytes) !== grant.files.get(path)) {
1020
+ remove(grant);
1021
+ error(409, "面板文件发生变化,请重新安装后打开。");
1022
+ }
1023
+ }
1024
+ // Relative source expressions remain tied to the resource response origin,
1025
+ // including when the public site is behind HTTPS termination.
1026
+ const source = publicPathPrefix + ASSETS + grant.asset + "/";
1027
+ if (extname(path).toLowerCase() === ".html") {
1028
+ bytes = Buffer.from(injectBridge(bytes.toString("utf8"), source));
1029
+ const resourceOrigin = grant.origin;
1030
+ const assetSource = resourceOrigin + source;
1031
+ response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
1032
+ assetSource +
1033
+ "; style-src " +
1034
+ assetSource +
1035
+ " 'unsafe-inline'; img-src " +
1036
+ assetSource +
1037
+ " data: blob:; media-src " +
1038
+ assetSource +
1039
+ " blob:; font-src " +
1040
+ assetSource +
1041
+ " data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts; frame-ancestors " +
1042
+ resourceOrigin);
1043
+ response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
1044
+ }
1045
+ if (!(await authorized(grant)))
1046
+ error(410, "面板授权已失效,请重新打开。");
1047
+ response.writeHead(200, {
1048
+ "Content-Type": MIME[extname(path).toLowerCase()],
1049
+ "Content-Length": bytes.length,
1050
+ });
1051
+ response.end(request.method === "HEAD" ? undefined : bytes);
1052
+ }
1053
+ catch (cause) {
1054
+ if (!(cause instanceof PanelManagementError) || cause.status === 409)
1055
+ remove(grant);
1056
+ json(response, cause instanceof PanelManagementError ? cause.status : 404, {
1057
+ error: cause instanceof Error ? cause.message : "面板资源不可用。",
1058
+ });
1059
+ }
1060
+ return true;
1061
+ },
1062
+ };
1063
+ }