@cjhyy/code-shell-server 0.9.22 → 0.9.23

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 (104) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/http.d.ts +11 -0
  51. package/dist/panels/resources/http.js +74 -0
  52. package/dist/panels/resources/references.d.ts +5 -0
  53. package/dist/panels/resources/references.js +26 -0
  54. package/dist/panels/runtime-services.js +16 -3
  55. package/dist/panels/runtime.d.ts +16 -4
  56. package/dist/panels/runtime.js +522 -64
  57. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  58. package/dist/panels/shared-tool-jobs.js +37 -0
  59. package/dist/panels/storage-store.d.ts +21 -0
  60. package/dist/panels/storage-store.js +43 -0
  61. package/dist/panels/task-cookie-host.d.ts +26 -0
  62. package/dist/panels/task-cookie-host.js +145 -0
  63. package/dist/panels/task-cookies.d.ts +51 -0
  64. package/dist/panels/task-cookies.js +183 -0
  65. package/dist/panels/tool-executor.d.ts +21 -5
  66. package/dist/panels/tool-executor.js +171 -30
  67. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  68. package/dist/panels/tool-jobs-storage.js +34 -0
  69. package/dist/panels/tool-jobs.d.ts +49 -3
  70. package/dist/panels/tool-jobs.js +270 -15
  71. package/dist/panels/types.d.ts +43 -0
  72. package/dist/project-runtime/backup.d.ts +26 -0
  73. package/dist/project-runtime/backup.js +500 -0
  74. package/dist/project-runtime/control-server.d.ts +4 -0
  75. package/dist/project-runtime/control-server.js +19 -1
  76. package/dist/project-runtime/docker-provider.d.ts +4 -0
  77. package/dist/project-runtime/docker-provider.js +16 -1
  78. package/dist/project-runtime/managed-entry.js +4 -0
  79. package/dist/project-runtime/registry.d.ts +2 -0
  80. package/dist/project-runtime/registry.js +15 -0
  81. package/dist/project-runtime/seccomp.d.ts +8 -0
  82. package/dist/project-runtime/seccomp.js +72 -0
  83. package/dist/project-runtime/types.d.ts +2 -0
  84. package/dist/remote-relay/byte-stream.d.ts +9 -0
  85. package/dist/remote-relay/byte-stream.js +71 -0
  86. package/dist/remote-relay/connector.d.ts +27 -0
  87. package/dist/remote-relay/connector.js +197 -0
  88. package/dist/remote-relay/local-stream.d.ts +10 -0
  89. package/dist/remote-relay/local-stream.js +120 -0
  90. package/dist/remote-relay/protocol.d.ts +39 -0
  91. package/dist/remote-relay/protocol.js +64 -0
  92. package/dist/serve/automation-worker.d.ts +16 -0
  93. package/dist/serve/automation-worker.js +126 -0
  94. package/dist/serve/cli.d.ts +3 -0
  95. package/dist/serve/cli.js +17 -0
  96. package/dist/serve/headless-server.d.ts +3 -0
  97. package/dist/serve/headless-server.js +140 -10
  98. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  99. package/dist/serve/settings-recovery-cli.js +128 -0
  100. package/dist/session-http-responses.d.ts +7 -0
  101. package/dist/session-http-responses.js +40 -0
  102. package/dist/worker-bridge-core.d.ts +5 -0
  103. package/dist/worker-bridge-core.js +23 -0
  104. package/package.json +15 -6
@@ -1,19 +1,24 @@
1
+ import { panelExecutionGate, PanelExecutionBusyError } from "./execution-gate.js";
1
2
  import { randomBytes, createHash } from "node:crypto";
2
3
  import { constants } from "node:fs";
3
4
  import { lstat, mkdir, open, opendir, realpath } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { dirname, extname, join, resolve, sep } from "node:path";
6
- import { listInstalledPanelApps, validateToolArgsStrict, } from "@cjhyy/code-shell-core";
7
+ import { listInstalledPanelApps, inspectProjectPanelApps, CredentialStore, validateToolArgsStrict, } from "@cjhyy/code-shell-core";
7
8
  import { panelRuntimeApiVersion, panelProcessMethods, panelResourceMethods, panelToolJobMethods, panelRuntimeCapabilities, PanelBridgeError, panelBridgeFailure, } from "./bridge-contract.js";
8
9
  import { PanelResourceService } from "./resources/service.js";
10
+ import { servePanelResource } from "./resources/http.js";
9
11
  import { panelConnections, panelConnectionIds, materializePanelConnections, } from "./connections.js";
10
12
  import { PanelRuntimeServices } from "./runtime-services.js";
11
13
  import { PanelToolJobService, toolJobLimits, } from "./tool-jobs.js";
12
14
  import { createPanelToolExecutor } from "./tool-executor.js";
13
- import { PanelAppDirectoryBookmarks } from "./directory-bookmarks.js";
15
+ import { PanelTaskCookieHost } from "./task-cookie-host.js";
16
+ import { taskCookieFromInput, taskCookieSelection, } from "./task-cookies.js";
17
+ import { desktopPanelDirectoryBookmarks, hubPanelDirectoryBookmarks, } from "./directory-bookmarks.js";
14
18
  import { handlePanelProcessDirectory } from "./process-files.js";
15
19
  import { PanelAppProcessService, panelExecutableDirectories, panelProcessInfo, } from "./process-service.js";
16
20
  import { PanelManagementError } from "./management.js";
21
+ import { panelAutomationMethods } from "./automations.js";
17
22
  const ASSETS = "/api/v1/panel-assets/";
18
23
  const ROOT = "/api/v1/panels/runtime/";
19
24
  const MAX_FILE = 16 * 1024 * 1024;
@@ -21,6 +26,8 @@ const GRANT_TTL = 30 * 60_000;
21
26
  const METHODS = [
22
27
  "context.get",
23
28
  "storage.get",
29
+ "storage.getSnapshot",
30
+ "storage.compareAndSet",
24
31
  "storage.set",
25
32
  "storage.delete",
26
33
  "workspace.info",
@@ -30,10 +37,16 @@ const METHODS = [
30
37
  "external.open",
31
38
  "agent.submitPrompt",
32
39
  "notifications.send",
40
+ ...panelAutomationMethods,
33
41
  ...panelProcessMethods,
34
42
  ...panelResourceMethods,
43
+ "resources.open",
44
+ "resources.preview",
45
+ "resources.recordAudio",
35
46
  ...panelToolJobMethods,
36
47
  "credentials.connections.list",
48
+ "credentials.cookies.listForTask",
49
+ "credentials.cookies.authorizeProcess",
37
50
  "credentials.connections.authorizeProcess",
38
51
  "agent.task.models",
39
52
  "agent.task.start",
@@ -55,7 +68,9 @@ const PERMISSIONS = new Set([
55
68
  "notifications.send",
56
69
  "process",
57
70
  "resources",
71
+ "resources.directRead",
58
72
  "credentials.connections",
73
+ "credentials.cookies",
59
74
  "agent.task",
60
75
  ]);
61
76
  const MIME = {
@@ -75,12 +90,44 @@ const MIME = {
75
90
  ".woff2": "font/woff2",
76
91
  ".ttf": "font/ttf",
77
92
  };
78
- export const panelWebCompatibility = (app) => ({
93
+ // The asset origin is also an allowed script source. Never expose uploaded
94
+ // documents or executable MIME types there, even with a resource permission.
95
+ const INLINE_RESOURCE_TYPES = new Set([
96
+ "image/png",
97
+ "image/jpeg",
98
+ "image/webp",
99
+ "image/gif",
100
+ "image/avif",
101
+ "image/bmp",
102
+ "audio/mpeg",
103
+ "audio/mp4",
104
+ "audio/wav",
105
+ "audio/x-wav",
106
+ "audio/ogg",
107
+ "audio/webm",
108
+ "audio/flac",
109
+ "audio/aac",
110
+ "video/mp4",
111
+ "video/webm",
112
+ "video/quicktime",
113
+ "font/woff",
114
+ "font/woff2",
115
+ "font/ttf",
116
+ "font/otf",
117
+ ]);
118
+ const INLINE_RESOURCE_PATH = "_codeshell_resources/";
119
+ export const panelWebCompatibility = (app, options) => ({
79
120
  supported: true,
80
121
  reasons: [
81
122
  ...app.permissions
82
- .filter((permission) => !PERMISSIONS.has(permission))
123
+ .filter((permission) => !PERMISSIONS.has(permission) &&
124
+ !(permission === "automations.manage" && options?.automations))
83
125
  .map((permission) => "网页暂不提供 " + permission + ",对应功能需要桌面客户端。"),
126
+ ...(app.permissions.includes("credentials.cookies")
127
+ ? [
128
+ "网页可选择 Host 已保存的账号用于后台任务和受授权的临时程序;登录采集和浏览器登录恢复仍需桌面端。",
129
+ ]
130
+ : []),
84
131
  ],
85
132
  });
86
133
  function methodPermission(method) {
@@ -88,12 +135,16 @@ function methodPermission(method) {
88
135
  return "resources";
89
136
  if (method.startsWith("credentials.connections."))
90
137
  return "credentials.connections";
138
+ if (method.startsWith("credentials.cookies."))
139
+ return "credentials.cookies";
91
140
  if (method.startsWith("storage."))
92
141
  return "storage";
93
142
  if (method.startsWith("process.") || method.startsWith("filesystem."))
94
143
  return "process";
95
144
  if (method.startsWith("agent.task."))
96
145
  return "agent.task";
146
+ if (method.startsWith("automations."))
147
+ return "automations.manage";
97
148
  if (method === "workspace.info")
98
149
  return "workspace.info";
99
150
  if (method === "workspace.writeText")
@@ -248,7 +299,7 @@ function bridgeScript(id, origin) {
248
299
  const requestId = String(++next);
249
300
  const timer = setTimeout(() => {
250
301
  pending.delete(requestId); reject(new Error("Panel request timed out"));
251
- }, method === "tasks.start" ? 30 * 60 * 1000 : 60000);
302
+ }, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio"].includes(method) ? 30 * 60 * 1000 : 60000);
252
303
  pending.set(requestId, { resolve, reject, timer });
253
304
  try {
254
305
  parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
@@ -357,6 +408,8 @@ function injectBridge(html, source) {
357
408
  /** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
358
409
  export function createPanelRuntime(options) {
359
410
  const publicPathPrefix = options.publicPathPrefix ?? "";
411
+ // An admitted task belongs to the project. Login/page grants only control access.
412
+ const projectOwnedTools = options.host === "hub" || !!options.sharedToolJobs;
360
413
  if (publicPathPrefix &&
361
414
  !/^\/p\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(publicPathPrefix))
362
415
  throw new Error("Invalid project public path prefix");
@@ -364,30 +417,112 @@ export function createPanelRuntime(options) {
364
417
  const assets = new Map();
365
418
  const now = options.now ?? Date.now;
366
419
  const services = new PanelRuntimeServices({ dataDir: options.dataDir });
367
- const installed = options.listInstalled ?? listInstalledPanelApps;
420
+ const installed = options.listInstalled ??
421
+ (options.projectPackages
422
+ ? async (appId) => (await inspectProjectPanelApps(options.bindingCwd ?? options.cwd, appId)).apps
423
+ : listInstalledPanelApps);
368
424
  let closed = false;
369
425
  let generation = 0;
370
426
  let nextGuest = 0;
371
427
  let nextToolOwner = -1;
372
428
  const byGuest = new Map();
373
- const directoryBookmarks = new PanelAppDirectoryBookmarks(join(options.dataDir, "panel-web-directory-bookmarks.json"));
429
+ const directoryBookmarks = options.host === "desktop"
430
+ ? desktopPanelDirectoryBookmarks(options.dataDir)
431
+ : hubPanelDirectoryBookmarks(options.dataDir);
374
432
  const panelDataDirectory = (appId) => join(options.dataDir, "panel-data", appId, digest(Buffer.from(options.bindingCwd ?? options.cwd)).slice(0, 24));
375
433
  const toolOwners = new Map();
376
434
  const jobOwners = new Map();
377
435
  let toolJobs;
436
+ let taskCookies;
437
+ function getTaskCookies() {
438
+ if (options.host !== "hub" || options.sharedToolJobs)
439
+ throw new PanelBridgeError("NOT_SUPPORTED", "Background Cookie access is unavailable");
440
+ return (taskCookies ??= new PanelTaskCookieHost({
441
+ rootDirectory: join(options.dataDir, "panel-task-cookies"),
442
+ // A cloud project must not inherit credentials from the controller's user account.
443
+ credentials: async (scope) => new CredentialStore(scope.projectPath).list("project"),
444
+ authorize: async (scope) => {
445
+ if (!(await installedToolApp(scope)).permissions.includes("credentials.cookies"))
446
+ throw new PanelBridgeError("PERMISSION_DENIED", "Tool requires Cookie permission");
447
+ },
448
+ }));
449
+ }
450
+ function cookieAccess(grant) {
451
+ if (!grant.app.permissions.includes("process") ||
452
+ !grant.app.permissions.includes("resources") ||
453
+ !grant.app.permissions.includes("credentials.cookies"))
454
+ error(403, "后台账号访问缺少面板权限。");
455
+ if (grant.sharedTools) {
456
+ if (!grant.sharedTools.cookies)
457
+ error(501, "当前 Host 不支持后台账号授权。");
458
+ return grant.sharedTools.cookies;
459
+ }
460
+ if (options.sharedToolJobs)
461
+ error(410, "共享任务授权已失效。");
462
+ const scope = {
463
+ appId: grant.app.id,
464
+ projectPath: options.bindingCwd ?? options.cwd,
465
+ revision: grant.revision,
466
+ };
467
+ const host = getTaskCookies();
468
+ return {
469
+ list: (url) => host.list(scope, url),
470
+ check: (selection) => host.check(scope, selection),
471
+ materialize: (selection) => host.materialize(scope, selection),
472
+ };
473
+ }
474
+ async function taskConsentDetail(grant, input, entry) {
475
+ const selection = taskCookieFromInput(input);
476
+ if (!selection)
477
+ return entry;
478
+ const account = await cookieAccess(grant).check(selection);
479
+ return `工具:${entry}\n账号:${account.label}\n站点:${new URL(selection.url).hostname}\n所选登录信息仅交付给已审查的后台程序,任务可在关闭页面后继续。`;
480
+ }
481
+ const localToolRoot = options.host === "desktop"
482
+ ? join(options.dataDir, "panel-web-tool-jobs", digest(Buffer.from(options.cwd)).slice(0, 24))
483
+ : join(options.dataDir, "panel-tool-jobs");
484
+ let legacyPresent;
485
+ async function legacyJobs(scope) {
486
+ if (!options.sharedToolJobs || options.host !== "desktop")
487
+ return [];
488
+ const present = await (legacyPresent ??= lstat(localToolRoot)
489
+ .then((info) => {
490
+ if (!info.isDirectory() || info.isSymbolicLink())
491
+ throw new Error("Invalid legacy task store");
492
+ return true;
493
+ })
494
+ .catch((cause) => {
495
+ if (cause.code === "ENOENT")
496
+ return false;
497
+ throw cause;
498
+ }));
499
+ // Opening an existing store marks unfinished jobs interrupted; it never replays them.
500
+ return present
501
+ ? (await getToolJobs().list(scope)).map((job) => ({
502
+ ...job,
503
+ readOnly: true,
504
+ historySource: "desktop-web-legacy",
505
+ }))
506
+ : [];
507
+ }
378
508
  async function installedToolApp(scope) {
379
509
  if (closed || scope.projectPath !== (options.bindingCwd ?? options.cwd))
380
510
  throw new PanelBridgeError("REVOKED", "Tool task workspace is unavailable");
381
- const panel = (await snapshot()).panels.find((candidate) => candidate.id === scope.appId);
382
- const app = (await installed()).find((candidate) => candidate.id === scope.appId);
511
+ const panel = (await snapshot(scope.appId)).panels.find((candidate) => candidate.id === scope.appId);
512
+ const app = (await installed(scope.appId)).find((candidate) => candidate.id === scope.appId);
383
513
  if (!panel?.enabled ||
384
514
  panel.revision !== scope.revision ||
515
+ (panel.packageDigest !== undefined && panel.packageDigest !== app?.packageDigest) ||
385
516
  !app?.permissions.includes("process") ||
386
517
  !app.permissions.includes("resources"))
387
518
  throw new PanelBridgeError("REVOKED", "Installed tool task is no longer authorized");
388
519
  return app;
389
520
  }
390
521
  const processes = new PanelAppProcessService({
522
+ acquireExecution: (owner) => panelExecutionGate.enter({
523
+ appId: owner.appId,
524
+ projectPath: options.bindingCwd ?? options.cwd,
525
+ }),
391
526
  approvalScope: "guest",
392
527
  resolvePackageEntry: async (owner, name) => {
393
528
  const taskScope = toolOwners.get(owner.guestId);
@@ -427,7 +562,7 @@ export function createPanelRuntime(options) {
427
562
  rootDirectory: join(options.dataDir, "panel-app-media"),
428
563
  isScopeAuthorized: async (scope) => {
429
564
  if (scope.projectPath === (options.bindingCwd ?? options.cwd)) {
430
- const panel = (await snapshot()).panels.find((candidate) => candidate.id === scope.appId);
565
+ const panel = (await snapshot(scope.appId)).panels.find((candidate) => candidate.id === scope.appId);
431
566
  if (panel?.enabled && panel.permissions.includes("resources"))
432
567
  return true;
433
568
  }
@@ -457,16 +592,33 @@ export function createPanelRuntime(options) {
457
592
  revision: grant.revision,
458
593
  };
459
594
  const input = (params ?? {});
460
- const service = getToolJobs();
595
+ const shared = grant.sharedTools;
596
+ const service = shared ?? {
597
+ start: (request, signal) => getToolJobs().start(scope, request, signal),
598
+ list: () => getToolJobs().list(scope),
599
+ get: (id) => getToolJobs().get(scope, id),
600
+ cancel: (id) => getToolJobs().cancel(scope, id),
601
+ retry: (id) => getToolJobs().retry(scope, id),
602
+ find: (requestKey) => getToolJobs().find(scope, requestKey),
603
+ getQueue: () => getToolJobs().getQueue(scope),
604
+ setQueue: (update) => getToolJobs().setQueue(scope, update),
605
+ };
606
+ if (options.sharedToolJobs && !shared)
607
+ error(410, "共享任务授权已失效,请重新打开面板。");
461
608
  if (method === "tasks.start") {
462
609
  const app = await installedToolApp(scope);
463
610
  const entry = app.nativeEntries?.[input.entry ?? ""];
464
611
  if (!entry)
465
612
  error(501, "安装的原生工具不可用。");
466
- if (!(await confirm(grant, `启动 ${grant.app.title.default} 的后台工具?`, input.entry)))
613
+ const detail = await taskConsentDetail(grant, input.input, input.entry);
614
+ if (!(await confirm(grant, `启动 ${grant.app.title.default} 的后台工具?`, detail)))
467
615
  error(403, "你取消了后台工具执行。");
468
616
  if (!(await authorized(grant)))
469
617
  error(410, "面板授权已失效。");
618
+ if (taskCookieFromInput(input.input))
619
+ await taskConsentDetail(grant, input.input, input.entry);
620
+ if (!(await authorized(grant)))
621
+ error(410, "面板授权已失效。");
470
622
  const preparation = {
471
623
  owner: grant.owner,
472
624
  appId: scope.appId,
@@ -474,31 +626,46 @@ export function createPanelRuntime(options) {
474
626
  };
475
627
  preparingTools.add(preparation);
476
628
  try {
477
- const job = await service.start(scope, {
629
+ const job = await service.start({
478
630
  entry: { name: input.entry, sha256: entry.sha256 },
479
631
  input: input.input,
480
632
  recovery: input.recovery ?? "manual",
481
633
  requestKey: input.requestKey,
482
634
  }, preparation.controller.signal);
483
- if (!jobOwners.has(job.id))
635
+ if (!projectOwnedTools && !jobOwners.has(job.id))
484
636
  jobOwners.set(job.id, { owner: grant.owner, scope });
485
637
  if (!(await authorized(grant))) {
486
- if (jobOwners.get(job.id)?.owner === grant.owner)
487
- await service.cancel(scope, job.id);
638
+ if (!projectOwnedTools && jobOwners.get(job.id)?.owner === grant.owner)
639
+ await service.cancel(job.id);
488
640
  error(410, "登录授权已撤销。");
489
641
  }
490
- emitTaskEvent(await service.get(scope, job.id));
642
+ if (!shared)
643
+ emitTaskEvent(await service.get(job.id));
491
644
  return job;
492
645
  }
493
646
  catch (cause) {
494
647
  if (preparation.controller.signal.aborted)
495
- error(410, "登录授权已撤销,输入准备已取消。");
648
+ error(410, projectOwnedTools
649
+ ? "登录授权已撤销。请重新连接后查询任务,已接收的项目任务可能仍在运行。"
650
+ : "登录授权已撤销,输入准备已取消。");
496
651
  throw cause;
497
652
  }
498
653
  finally {
499
654
  preparingTools.delete(preparation);
500
655
  }
501
656
  }
657
+ if (method === "tasks.find")
658
+ return service.find(input.requestKey);
659
+ if (method === "tasks.queue.get")
660
+ return service.getQueue();
661
+ if (method === "tasks.queue.set") {
662
+ // Resuming may launch previously admitted jobs; use the existing owner consent gate.
663
+ if (!(await confirm(grant, `调整 ${grant.app.title.default} 的后台队列?`, "暂停只影响等待任务,正在执行的任务继续运行。")))
664
+ error(403, "你取消了队列调整。");
665
+ if (!(await authorized(grant)))
666
+ error(410, "面板授权已失效。");
667
+ return service.setQueue(params);
668
+ }
502
669
  if (method === "tasks.list") {
503
670
  const offset = input.offset ?? 0, limit = input.limit ?? 50;
504
671
  if (!Number.isSafeInteger(offset) ||
@@ -507,26 +674,48 @@ export function createPanelRuntime(options) {
507
674
  limit < 1 ||
508
675
  limit > 50)
509
676
  throw new PanelBridgeError("INVALID_ARGUMENT", "Invalid task page");
510
- return (await service.list(scope))
511
- .slice(offset, offset + limit)
512
- .map((job) => toolSummary(job));
677
+ const jobs = [...(await service.list()), ...(await legacyJobs(scope))].sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
678
+ return jobs.slice(offset, offset + limit).map((job) => toolSummary(job));
679
+ }
680
+ if (shared && ["tasks.get", "tasks.cancel", "tasks.retry"].includes(method)) {
681
+ // Explicitly identify legacy IDs. Authorization failures on the shared service
682
+ // must never fall through to a less restrictive coordinator.
683
+ if (!(await shared.has(input.id))) {
684
+ const legacy = (await legacyJobs(scope)).find((job) => job.id === input.id);
685
+ if (legacy) {
686
+ if (method === "tasks.get")
687
+ return legacy;
688
+ throw new PanelBridgeError("NOT_SUPPORTED", "旧网页任务仅供查看,请从原输入创建新的共享任务。");
689
+ }
690
+ }
513
691
  }
514
692
  if (method === "tasks.get")
515
- return service.get(scope, input.id);
693
+ return service.get(input.id);
516
694
  if (method === "tasks.cancel")
517
- return service.cancel(scope, input.id);
695
+ return service.cancel(input.id);
518
696
  if (method === "tasks.retry") {
519
- if (!(await confirm(grant, `重试 ${grant.app.title.default} 的后台工具?`, input.id ?? "")))
697
+ const previous = await service.get(input.id);
698
+ if (previous.readOnly)
699
+ throw new PanelBridgeError("NOT_SUPPORTED", "旧任务仅供查看,请检查输入后创建新任务。");
700
+ const detail = await taskConsentDetail(grant, previous.input, previous.entry.name);
701
+ if (!(await confirm(grant, `重试 ${grant.app.title.default} 的后台工具?`, detail)))
520
702
  error(403, "你取消了后台工具重试。");
521
703
  if (!(await authorized(grant)))
522
704
  error(410, "面板授权已失效。");
523
- const job = await service.retry(scope, input.id);
524
- jobOwners.set(job.id, { owner: grant.owner, scope });
705
+ if (taskCookieFromInput(previous.input))
706
+ await taskConsentDetail(grant, previous.input, previous.entry.name);
707
+ if (!(await authorized(grant)))
708
+ error(410, "面板授权已失效。");
709
+ const job = await service.retry(input.id);
710
+ if (!projectOwnedTools)
711
+ jobOwners.set(job.id, { owner: grant.owner, scope });
525
712
  if (!(await authorized(grant))) {
526
- await service.cancel(scope, job.id);
527
- error(410, "登录授权已撤销,后台工具已取消。");
713
+ if (!projectOwnedTools)
714
+ await service.cancel(job.id);
715
+ error(410, "登录授权已撤销,请重新连接后查看任务状态。");
528
716
  }
529
- emitTaskEvent(await service.get(scope, job.id));
717
+ if (!shared)
718
+ emitTaskEvent(await service.get(job.id));
530
719
  return job;
531
720
  }
532
721
  throw new PanelBridgeError("NOT_SUPPORTED", "Unknown tool task operation");
@@ -537,10 +726,11 @@ export function createPanelRuntime(options) {
537
726
  }
538
727
  function emitTaskEvent(job) {
539
728
  const owner = jobOwners.get(job.id)?.owner;
540
- if (!owner)
729
+ if (!projectOwnedTools && !owner)
541
730
  return;
542
731
  for (const grant of grants.values())
543
- if (grant.owner === owner &&
732
+ if ((projectOwnedTools || grant.owner === owner) &&
733
+ job.scope.projectPath === (options.bindingCwd ?? options.cwd) &&
544
734
  grant.app.id === job.scope.appId &&
545
735
  grant.revision === job.scope.revision &&
546
736
  grant.app.permissions.includes("process") &&
@@ -553,6 +743,7 @@ export function createPanelRuntime(options) {
553
743
  if (toolJobs)
554
744
  return toolJobs;
555
745
  const executor = createPanelToolExecutor({
746
+ ...(options.host === "hub" && !options.sharedToolJobs ? { cookies: getTaskCookies() } : {}),
556
747
  processes,
557
748
  resources,
558
749
  owner: (job, send) => {
@@ -577,6 +768,15 @@ export function createPanelRuntime(options) {
577
768
  if (!app.permissions.includes("credentials.connections"))
578
769
  throw new PanelBridgeError("PERMISSION_DENIED", "Tool requires connection permission");
579
770
  },
771
+ authorizeDirectRead: async (scope) => {
772
+ const app = await installedToolApp(scope);
773
+ if (!app.permissions.includes("resources.directRead"))
774
+ throw new PanelBridgeError("PERMISSION_DENIED", "Tool requires direct read permission");
775
+ },
776
+ resolveDirectoryBookmark: async (scope, bookmark) => {
777
+ await installedToolApp(scope);
778
+ return directoryBookmarks.restore(scope.appId, scope.projectPath, bookmark);
779
+ },
580
780
  appDataDirectory: async (scope) => {
581
781
  await installedToolApp(scope);
582
782
  const path = panelDataDirectory(scope.appId);
@@ -586,27 +786,31 @@ export function createPanelRuntime(options) {
586
786
  sealedRoot: join(options.dataDir, "panel-app-sealed"),
587
787
  });
588
788
  toolJobs = new PanelToolJobService({
589
- // Desktop's IPC bridge has its own in-memory coordinator for panel-tool-jobs.
590
- // A Desktop Web workspace must not open the same store concurrently.
591
- rootDir: options.host === "desktop"
592
- ? join(options.dataDir, "panel-web-tool-jobs", digest(Buffer.from(options.cwd)).slice(0, 24))
593
- : join(options.dataDir, "panel-tool-jobs"),
789
+ // Separate legacy store when a Desktop coordinator is injected. Existing
790
+ // history stays readable; shared jobs never acquire a second disk lock.
791
+ rootDir: localToolRoot,
594
792
  ...executor,
595
793
  isAuthorized: async (scope) => !!(await installedToolApp(scope).catch(() => null)),
794
+ describePackage: async (scope) => {
795
+ const app = await installedToolApp(scope);
796
+ if (!app.packageDigest)
797
+ throw new Error("Project package digest is unavailable");
798
+ return { version: app.version, packageDigest: app.packageDigest };
799
+ },
596
800
  onEvent: emitTaskEvent,
597
801
  });
598
802
  return toolJobs;
599
803
  }
600
- let readingSnapshot;
601
- function snapshot() {
804
+ const readingSnapshots = new Map();
805
+ function snapshot(appId) {
602
806
  // Parallel module requests share current disk work, without a stale TTL
603
807
  // that could keep an unbound or updated package authorized.
604
- const pending = readingSnapshot ?? options.snapshot();
605
- readingSnapshot = pending;
808
+ const pending = readingSnapshots.get(appId) ?? options.snapshot(appId);
809
+ readingSnapshots.set(appId, pending);
606
810
  void pending
607
811
  .finally(() => {
608
- if (readingSnapshot === pending)
609
- readingSnapshot = undefined;
812
+ if (readingSnapshots.get(appId) === pending)
813
+ readingSnapshots.delete(appId);
610
814
  })
611
815
  .catch(() => { });
612
816
  return pending;
@@ -614,6 +818,7 @@ export function createPanelRuntime(options) {
614
818
  function remove(grant) {
615
819
  if (grants.get(grant.id) !== grant)
616
820
  return;
821
+ grant.unsubscribeTools?.();
617
822
  grants.delete(grant.id);
618
823
  assets.delete(grant.asset);
619
824
  byGuest.delete(grant.guestId);
@@ -704,6 +909,9 @@ export function createPanelRuntime(options) {
704
909
  };
705
910
  }
706
911
  async function onPanelAction(scope, input) {
912
+ return panelExecutionGate.run({ appId: scope.appId, projectPath: options.bindingCwd ?? options.cwd }, () => onPanelActionAdmitted(scope, input));
913
+ }
914
+ async function onPanelActionAdmitted(scope, input) {
707
915
  const grant = grants.get(scope.instanceId);
708
916
  if (!grant || grant.owner !== scope.ownerId || !(await authorized(grant)))
709
917
  return { ok: false, detail: "面板已关闭或授权已撤销。" };
@@ -737,13 +945,20 @@ export function createPanelRuntime(options) {
737
945
  return { ok: false, detail: "用户取消了面板工具操作。" };
738
946
  if (grant.toolCalls.size >= 16)
739
947
  return { ok: false, detail: "面板工具调用过多。" };
948
+ const releaseExecution = panelExecutionGate.enter({
949
+ appId: grant.app.id,
950
+ projectPath: options.bindingCwd ?? options.cwd,
951
+ });
740
952
  const requestId = randomBytes(18).toString("base64url");
741
953
  const value = await new Promise((done) => {
742
- const timer = setTimeout(() => finish({ error: "面板工具调用超时。" }), 45_000);
954
+ // Keep the operation occupied after the caller stops waiting. A late reply
955
+ // or revoked guest ends it; a timeout alone is not proof of termination.
956
+ const timer = setTimeout(() => done({ error: "面板工具调用超时。" }), 45_000);
743
957
  timer.unref();
744
958
  const finish = (result) => {
745
959
  if (!grant.toolCalls.delete(requestId))
746
960
  return;
961
+ releaseExecution();
747
962
  clearTimeout(timer);
748
963
  done(result);
749
964
  };
@@ -783,10 +998,12 @@ export function createPanelRuntime(options) {
783
998
  return false;
784
999
  }
785
1000
  try {
786
- const panel = (await snapshot()).panels.find((candidate) => candidate.id === grant.app.id);
1001
+ const panel = (await snapshot(grant.app.id)).panels.find((candidate) => candidate.id === grant.app.id);
787
1002
  const info = await lstat(grant.root);
788
1003
  const ownerStillAuthorized = await options.isAuthorized(grant.request);
789
1004
  const currentOwner = await options.ownerId(grant.request);
1005
+ if (grant.app.permissions.includes("process"))
1006
+ await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
790
1007
  const valid = !closed &&
791
1008
  grants.get(grant.id) === grant &&
792
1009
  grant.expiresAt > now() &&
@@ -825,7 +1042,7 @@ export function createPanelRuntime(options) {
825
1042
  !/^[a-f0-9]{64}$/.test(input.revision) ||
826
1043
  Object.keys(input).some((key) => !["appId", "revision", "sessionId", "theme", "locale"].includes(key)))
827
1044
  error(400, "面板参数无效。");
828
- const panel = (await snapshot()).panels.find((candidate) => candidate.id === input.appId);
1045
+ const panel = (await snapshot(input.appId)).panels.find((candidate) => candidate.id === input.appId);
829
1046
  if (!panel?.enabled || !panel.compatibility.supported)
830
1047
  error(403, "请先将面板绑定到当前工作区。");
831
1048
  if (panel.revision !== input.revision)
@@ -833,9 +1050,11 @@ export function createPanelRuntime(options) {
833
1050
  if (input.sessionId !== undefined &&
834
1051
  (typeof input.sessionId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/.test(input.sessionId)))
835
1052
  error(400, "会话标识无效。");
836
- const app = (await installed()).find((candidate) => candidate.id === input.appId);
1053
+ const app = (await installed(input.appId)).find((candidate) => candidate.id === input.appId);
837
1054
  if (!app)
838
1055
  error(404, "面板已卸载。");
1056
+ if (panel.packageDigest && panel.packageDigest !== app.packageDigest)
1057
+ error(409, "项目面板版本已改变,请重新打开。");
839
1058
  const root = await realpath(app.installPath), info = await lstat(app.installPath);
840
1059
  if (info.isSymbolicLink() || !info.isDirectory())
841
1060
  error(403, "面板安装目录无效。");
@@ -844,6 +1063,13 @@ export function createPanelRuntime(options) {
844
1063
  error(404, "找不到面板入口。");
845
1064
  if (closed || generation !== startedGeneration || preparation.cancelled)
846
1065
  error(410, "面板授权已失效,请重新打开。");
1066
+ const sharedTools = options.sharedToolJobs &&
1067
+ app.permissions.includes("process") &&
1068
+ app.permissions.includes("resources")
1069
+ ? await options.sharedToolJobs.bind(app, options.bindingCwd ?? options.cwd)
1070
+ : undefined;
1071
+ if (closed || generation !== startedGeneration || preparation.cancelled)
1072
+ error(410, "面板授权已失效,请重新打开。");
847
1073
  for (const grant of grants.values())
848
1074
  if (grant.expiresAt <= now())
849
1075
  remove(grant);
@@ -860,12 +1086,32 @@ export function createPanelRuntime(options) {
860
1086
  capabilities: {
861
1087
  ...panelRuntimeCapabilities({
862
1088
  process: app.permissions.includes("process"),
1089
+ cookieProcess: app.permissions.includes("process") &&
1090
+ app.permissions.includes("resources") &&
1091
+ app.permissions.includes("credentials.cookies") &&
1092
+ (sharedTools
1093
+ ? !!sharedTools.cookies?.materialize
1094
+ : options.host === "hub" && !options.sharedToolJobs),
863
1095
  resources: app.permissions.includes("resources") ? resources.capabilities() : undefined,
864
1096
  tasks: app.permissions.includes("process") && app.permissions.includes("resources")
865
1097
  ? {
866
1098
  available: true,
1099
+ directoryBookmarks: true,
1100
+ queueControl: true,
1101
+ cookieCredentials: app.permissions.includes("credentials.cookies") &&
1102
+ (sharedTools
1103
+ ? !!sharedTools.cookies
1104
+ : options.host === "hub" && !options.sharedToolJobs),
867
1105
  ...toolJobLimits,
1106
+ ownership: projectOwnedTools ? "project" : "session",
1107
+ executionRevision: sharedTools?.scope.revision ?? panel.revision,
1108
+ sharedAcrossDevices: projectOwnedTools,
1109
+ continuesAfterDisconnect: true,
1110
+ continuesAfterLogout: projectOwnedTools,
868
1111
  maxHttpResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
1112
+ ...(app.permissions.includes("resources.directRead")
1113
+ ? { directRead: true }
1114
+ : {}),
869
1115
  }
870
1116
  : undefined,
871
1117
  limits: {
@@ -878,25 +1124,52 @@ export function createPanelRuntime(options) {
878
1124
  consentTimeoutMs: 50000,
879
1125
  },
880
1126
  }),
881
- ...(app.permissions.includes("process") && app.permissions.includes("resources")
1127
+ ...(app.permissions.includes("resources")
882
1128
  ? {
883
1129
  methodLimits: {
884
- "tasks.start": {
885
- maxParamsBytes: toolJobLimits.maxInputBytes,
886
- maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
887
- timeoutMs: 30 * 60 * 1000,
888
- },
889
- "tasks.get": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
890
- "tasks.retry": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
891
- "tasks.cancel": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
1130
+ "resources.recordAudio": { timeoutMs: 30 * 60 * 1000 },
1131
+ ...(app.permissions.includes("process")
1132
+ ? {
1133
+ "tasks.start": {
1134
+ maxParamsBytes: toolJobLimits.maxInputBytes,
1135
+ maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
1136
+ timeoutMs: 30 * 60 * 1000,
1137
+ },
1138
+ "tasks.find": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
1139
+ "tasks.get": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
1140
+ "tasks.retry": {
1141
+ maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
1142
+ timeoutMs: 30 * 60 * 1000,
1143
+ },
1144
+ "tasks.cancel": {
1145
+ maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
1146
+ },
1147
+ }
1148
+ : {}),
892
1149
  },
893
1150
  }
894
1151
  : {}),
895
1152
  },
896
1153
  host: options.host,
897
1154
  availableMethods: METHODS.filter((method) => {
1155
+ if (method === "credentials.cookies.listForTask" ||
1156
+ method === "credentials.cookies.authorizeProcess")
1157
+ return (app.permissions.includes("credentials.cookies") &&
1158
+ app.permissions.includes("process") &&
1159
+ app.permissions.includes("resources") &&
1160
+ (sharedTools
1161
+ ? method === "credentials.cookies.authorizeProcess"
1162
+ ? !!sharedTools.cookies?.materialize
1163
+ : !!sharedTools.cookies
1164
+ : options.host === "hub" && !options.sharedToolJobs));
898
1165
  if (method.startsWith("agent.task.") && !agentTasks)
899
1166
  return false;
1167
+ if (method.startsWith("automations."))
1168
+ return (!!options.automations &&
1169
+ (!method.endsWith("IfRevision") ||
1170
+ options.automations.conditionalMutations === true) &&
1171
+ ["automations.manage", "context.workspace", "context.session"].every((permission) => app.permissions.includes(permission)) &&
1172
+ !!input.sessionId);
900
1173
  if (method.startsWith("tasks."))
901
1174
  return app.permissions.includes("process") && app.permissions.includes("resources");
902
1175
  if ([
@@ -923,6 +1196,7 @@ export function createPanelRuntime(options) {
923
1196
  context.busy = false;
924
1197
  }
925
1198
  const grant = {
1199
+ sharedTools,
926
1200
  guestId: ++nextGuest,
927
1201
  id,
928
1202
  asset,
@@ -948,6 +1222,27 @@ export function createPanelRuntime(options) {
948
1222
  grants.set(id, grant);
949
1223
  byGuest.set(grant.guestId, grant);
950
1224
  assets.set(asset, grant);
1225
+ if (sharedTools) {
1226
+ // Keep event order across asynchronous viewer-authorization checks. A slow
1227
+ // or revoked viewer cannot stop the owning coordinator or another viewer.
1228
+ let events = Promise.resolve();
1229
+ let pendingEvents = 0;
1230
+ grant.unsubscribeTools = sharedTools.subscribe((job) => {
1231
+ if (++pendingEvents > 1024) {
1232
+ remove(grant);
1233
+ return;
1234
+ }
1235
+ events = events
1236
+ .then(async () => {
1237
+ // The native Host may have revoked project trust independently of
1238
+ // this Web session. Check both authorities before sending metadata.
1239
+ if ((await sharedTools.has(job.id)) && (await authorized(grant)))
1240
+ emit(grant, "tasks.changed", job);
1241
+ pendingEvents--;
1242
+ })
1243
+ .catch(() => remove(grant));
1244
+ });
1245
+ }
951
1246
  if (!(await authorized(grant))) {
952
1247
  remove(grant);
953
1248
  error((await options.isAuthorized(request)) ? 410 : 401, "面板授权已失效,请重新打开。");
@@ -965,6 +1260,9 @@ export function createPanelRuntime(options) {
965
1260
  }
966
1261
  }
967
1262
  async function call(grant, input) {
1263
+ return panelExecutionGate.run({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, () => callAdmitted(grant, input));
1264
+ }
1265
+ async function callAdmitted(grant, input) {
968
1266
  if (!(await authorized(grant)))
969
1267
  error(410, "面板授权已失效,请重新打开。");
970
1268
  if (Object.keys(input).some((key) => !["method", "params"].includes(key)))
@@ -1003,6 +1301,23 @@ export function createPanelRuntime(options) {
1003
1301
  const permission = methodPermission(method);
1004
1302
  if (!grant.app.permissions.includes(permission))
1005
1303
  error(403, "面板未声明这个权限。");
1304
+ if (method.startsWith("automations.")) {
1305
+ if (!options.automations)
1306
+ error(501, "当前执行环境尚未接入面板自动化。");
1307
+ if (method.endsWith("IfRevision") && !options.automations.conditionalMutations)
1308
+ error(501, "当前执行环境尚未接入自动化版本检查。");
1309
+ if (!grant.app.permissions.includes("context.workspace") ||
1310
+ !grant.app.permissions.includes("context.session") ||
1311
+ typeof grant.context.sessionId !== "string")
1312
+ error(403, "自动化需要已绑定的项目与对话。");
1313
+ return options.automations.call({
1314
+ appId: grant.app.id,
1315
+ cwd: options.cwd,
1316
+ sessionId: grant.context.sessionId,
1317
+ revision: grant.revision,
1318
+ isAuthorized: () => authorized(grant),
1319
+ }, method, params);
1320
+ }
1006
1321
  const processOwner = {
1007
1322
  guestId: grant.guestId,
1008
1323
  appId: grant.app.id,
@@ -1011,6 +1326,49 @@ export function createPanelRuntime(options) {
1011
1326
  send: (event, payload) => emit(grant, event, payload),
1012
1327
  };
1013
1328
  if (method.startsWith("resources.")) {
1329
+ if (method === "resources.recordAudio") {
1330
+ const value = (params ?? {});
1331
+ const maxDurationSeconds = value.maxDurationSeconds ?? 300;
1332
+ const maxBytes = value.maxBytes ?? 16 * 1024 * 1024;
1333
+ if (!value ||
1334
+ typeof value !== "object" ||
1335
+ Array.isArray(value) ||
1336
+ Object.keys(value).some((key) => !["maxDurationSeconds", "maxBytes"].includes(key)) ||
1337
+ !Number.isInteger(maxDurationSeconds) ||
1338
+ Number(maxDurationSeconds) < 1 ||
1339
+ Number(maxDurationSeconds) > 600 ||
1340
+ !Number.isInteger(maxBytes) ||
1341
+ Number(maxBytes) < 1 ||
1342
+ Number(maxBytes) > 25 * 1024 * 1024)
1343
+ error(400, "录音限制无效。");
1344
+ // This effect only opens a trusted chooser. Device access and upload each
1345
+ // require an explicit user action; the iframe retains microphone=().
1346
+ return { effect: "resources.recordAudio", maxDurationSeconds, maxBytes };
1347
+ }
1348
+ if (method === "resources.open" || method === "resources.preview") {
1349
+ const value = params;
1350
+ if (!value ||
1351
+ Object.keys(value).some((key) => key !== "assetId") ||
1352
+ typeof value.assetId !== "string" ||
1353
+ !/^(?:asset|external)-[a-f0-9]{64}$/.test(value.assetId))
1354
+ error(400, "文件资源标识无效。");
1355
+ const asset = await resources.get({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, value.assetId);
1356
+ if (!(await authorized(grant)))
1357
+ error(410, "面板授权已失效。");
1358
+ if (method === "resources.preview") {
1359
+ if (!INLINE_RESOURCE_TYPES.has(asset.mimeType))
1360
+ error(400, "此资源类型不能在面板内播放或显示。");
1361
+ return {
1362
+ asset,
1363
+ url: `${grant.origin}${publicPathPrefix}${ASSETS}${grant.asset}/${INLINE_RESOURCE_PATH}${asset.id}`,
1364
+ };
1365
+ }
1366
+ return {
1367
+ effect: "resources.open",
1368
+ asset,
1369
+ url: `${ROOT}${grant.id}/resources/${asset.id}`,
1370
+ };
1371
+ }
1014
1372
  if ([
1015
1373
  "resources.materialize",
1016
1374
  "resources.capture",
@@ -1021,6 +1379,51 @@ export function createPanelRuntime(options) {
1021
1379
  error(403, "资源工具交接需要process权限。");
1022
1380
  return resources.dispatch({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, method, params, { resolveDirectory: (handle) => processes.directoryPath(processOwner, handle) });
1023
1381
  }
1382
+ if (method === "credentials.cookies.listForTask") {
1383
+ const value = await cookieAccess(grant).list(params?.url);
1384
+ if (!(await authorized(grant)))
1385
+ error(410, "面板授权已失效。");
1386
+ return value;
1387
+ }
1388
+ if (method === "credentials.cookies.authorizeProcess") {
1389
+ const value = params;
1390
+ const selection = taskCookieSelection({
1391
+ credentialId: value?.credentialId,
1392
+ url: value?.url,
1393
+ revision: value?.revision,
1394
+ });
1395
+ const access = cookieAccess(grant);
1396
+ if (!access.materialize)
1397
+ error(501, "当前 Host 不支持临时进程账号授权。");
1398
+ const executable = processes.executableName(processOwner, value.executableHandle);
1399
+ const account = await access.check(selection);
1400
+ if (!(await confirm(grant, "允许程序使用这个已保存账号?", `程序:${executable}\n账号:${account.label}\n站点:${new URL(selection.url).hostname}\n登录信息仅通过私密文件交给所选程序;关闭页面或撤销授权会停止使用。`)))
1401
+ return { authorized: false, cancelled: true };
1402
+ const validate = async () => {
1403
+ await access.check(selection);
1404
+ if (!(await authorized(grant)))
1405
+ error(410, "面板授权已失效。");
1406
+ };
1407
+ await validate();
1408
+ const lease = await access.materialize(selection);
1409
+ try {
1410
+ await validate();
1411
+ const sealed = await processes.grantFileArgument(processOwner, {
1412
+ executableHandle: value.executableHandle,
1413
+ argumentName: "--cookies",
1414
+ path: lease.path,
1415
+ validate,
1416
+ cleanup: () => {
1417
+ void lease.cleanup().catch(() => { });
1418
+ },
1419
+ });
1420
+ return { authorized: true, fileArgumentHandle: sealed.handle, count: lease.count };
1421
+ }
1422
+ catch (cause) {
1423
+ await lease.cleanup();
1424
+ throw cause;
1425
+ }
1426
+ }
1024
1427
  if (method === "credentials.connections.list")
1025
1428
  return panelConnections(options.cwd);
1026
1429
  if (method === "credentials.connections.authorizeProcess") {
@@ -1089,10 +1492,14 @@ export function createPanelRuntime(options) {
1089
1492
  };
1090
1493
  }
1091
1494
  if (method === "filesystem.restoreDirectory") {
1495
+ await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1092
1496
  const saved = directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1093
- if (saved !== (await realpath(join(options.cwd, "downloads"))))
1497
+ if (!options.authorizePanelDirectory &&
1498
+ saved !== (await realpath(options.cwd)) &&
1499
+ saved !== (await realpath(join(options.cwd, "downloads"))))
1094
1500
  throw new PanelBridgeError("PERMISSION_DENIED", "Saved server directory is unavailable");
1095
1501
  const restored = await processes.grantDirectory(owner, saved);
1502
+ await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1096
1503
  directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1097
1504
  return { ...restored, bookmark: params.bookmark };
1098
1505
  }
@@ -1111,12 +1518,13 @@ export function createPanelRuntime(options) {
1111
1518
  if (method === "filesystem.pickDirectory" &&
1112
1519
  !(await confirm(grant, "使用服务端下载目录?", directory)))
1113
1520
  return null;
1521
+ await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1114
1522
  if (name !== "project")
1115
1523
  await mkdir(directory, { recursive: true, mode: 0o700 });
1116
1524
  if (!(await authorized(grant)))
1117
1525
  error(410, "面板授权已失效。");
1118
1526
  const selected = await processes.grantDirectory(owner, directory);
1119
- if (method !== "filesystem.pickDirectory")
1527
+ if (method !== "filesystem.pickDirectory" && !["downloads", "project"].includes(String(name)))
1120
1528
  return selected;
1121
1529
  const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
1122
1530
  if (!(await authorized(grant)))
@@ -1175,7 +1583,9 @@ export function createPanelRuntime(options) {
1175
1583
  }, method, params);
1176
1584
  }
1177
1585
  return {
1178
- activeTaskCount: () => (agentTasks?.activeTaskCount?.() ?? 0) + (toolJobs?.activeCount() ?? 0),
1586
+ activeTaskCount: () => (agentTasks?.activeTaskCount?.() ?? 0) +
1587
+ (toolJobs?.activeCount() ?? 0) +
1588
+ (options.sharedToolJobs?.activeCount(options.bindingCwd ?? options.cwd) ?? 0),
1179
1589
  async panelAction(ownerId, sessionId, input) {
1180
1590
  const candidates = [];
1181
1591
  for (const grant of grants.values()) {
@@ -1213,12 +1623,13 @@ export function createPanelRuntime(options) {
1213
1623
  for (const grant of grants.values())
1214
1624
  if (!appId || grant.app.id === appId)
1215
1625
  remove(grant);
1626
+ const sharedInvalidation = options.sharedToolJobs?.invalidate(options.bindingCwd ?? options.cwd, appId);
1216
1627
  if (!toolJobs)
1217
- return Promise.resolve();
1628
+ return sharedInvalidation ?? Promise.resolve();
1218
1629
  const appIds = appId
1219
1630
  ? [appId]
1220
1631
  : [...new Set([...jobOwners.values()].map((record) => record.scope.appId))];
1221
- return Promise.all(appIds.map((id) => toolJobs.cancelApp(id))).then(() => undefined);
1632
+ return Promise.all([sharedInvalidation, ...appIds.map((id) => toolJobs.cancelApp(id))]).then(() => undefined);
1222
1633
  },
1223
1634
  async close() {
1224
1635
  closed = true;
@@ -1232,6 +1643,7 @@ export function createPanelRuntime(options) {
1232
1643
  preparation.cancelled = true;
1233
1644
  try {
1234
1645
  await toolJobs?.shutdown();
1646
+ await taskCookies?.shutdown();
1235
1647
  }
1236
1648
  finally {
1237
1649
  processes.close();
@@ -1252,7 +1664,10 @@ export function createPanelRuntime(options) {
1252
1664
  return true;
1253
1665
  }
1254
1666
  const directoryMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/directory\/([a-f0-9-]{36})$/.exec(url.pathname);
1255
- const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|tool-results))?$/.exec(url.pathname) ?? directoryMatch;
1667
+ const resourceMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/resources\/((?:asset|external)-[a-f0-9]{64})$/.exec(url.pathname);
1668
+ const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|tool-results))?$/.exec(url.pathname) ??
1669
+ directoryMatch ??
1670
+ resourceMatch;
1256
1671
  const grant = match && grants.get(match[1]);
1257
1672
  if (!grant)
1258
1673
  error(410, "面板连接已失效,请关闭后重新打开。");
@@ -1261,7 +1676,26 @@ export function createPanelRuntime(options) {
1261
1676
  activeGrant = grant;
1262
1677
  if (!(await authorized(grant)))
1263
1678
  error(410, "面板连接已失效,请关闭后重新打开。");
1264
- if (directoryMatch) {
1679
+ if (resourceMatch) {
1680
+ if (!grant.app.permissions.includes("resources"))
1681
+ error(403, "面板未声明资源权限。");
1682
+ if ([...url.searchParams.keys()].some((key) => !["download", "workspace"].includes(key)) ||
1683
+ url.searchParams.getAll("download").length > 1 ||
1684
+ url.searchParams.getAll("workspace").length > 1 ||
1685
+ (url.searchParams.has("download") && url.searchParams.get("download") !== "1") ||
1686
+ (url.searchParams.has("workspace") && url.searchParams.get("workspace") !== options.cwd))
1687
+ error(400, "文件访问参数无效。");
1688
+ await servePanelResource(request, response, {
1689
+ service: resources,
1690
+ scope: { appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd },
1691
+ id: resourceMatch[2],
1692
+ download: url.searchParams.get("download") === "1",
1693
+ isAuthorized: async () => currentOwner === grant.owner &&
1694
+ (await options.isAuthorized(request)) &&
1695
+ (await authorized(grant)),
1696
+ });
1697
+ }
1698
+ else if (directoryMatch) {
1265
1699
  if (!grant.app.permissions.includes("process"))
1266
1700
  error(403, "面板未声明这个权限。");
1267
1701
  const root = processes.directoryPath({
@@ -1346,7 +1780,7 @@ export function createPanelRuntime(options) {
1346
1780
  error(405, "不支持这个面板请求。");
1347
1781
  }
1348
1782
  catch (cause) {
1349
- let status = cause instanceof PanelManagementError
1783
+ let status = cause instanceof PanelManagementError || cause instanceof PanelExecutionBusyError
1350
1784
  ? cause.status
1351
1785
  : cause instanceof PanelBridgeError && cause.code === "RATE_LIMITED"
1352
1786
  ? 429
@@ -1398,6 +1832,27 @@ export function createPanelRuntime(options) {
1398
1832
  return true;
1399
1833
  }
1400
1834
  const path = parts.map(decodeURIComponent).join("/");
1835
+ if (path.startsWith(INLINE_RESOURCE_PATH)) {
1836
+ if (!grant.app.permissions.includes("resources"))
1837
+ error(403, "面板未声明资源权限。");
1838
+ const id = path.slice(INLINE_RESOURCE_PATH.length);
1839
+ if (!/^(?:asset|external)-[a-f0-9]{64}$/.test(id))
1840
+ error(404, "文件资源标识无效。");
1841
+ const scope = { appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd };
1842
+ const asset = await resources.get(scope, id).catch(() => error(404, "文件资源不可用。"));
1843
+ if (!INLINE_RESOURCE_TYPES.has(asset.mimeType))
1844
+ error(403, "资源类型不可预览。");
1845
+ // A direct navigation must not turn project data into an active document.
1846
+ response.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
1847
+ await servePanelResource(request, response, {
1848
+ service: resources,
1849
+ scope,
1850
+ id,
1851
+ download: false,
1852
+ isAuthorized: () => authorized(grant),
1853
+ });
1854
+ return true;
1855
+ }
1401
1856
  let bytes;
1402
1857
  if (path === "_codeshell_bridge.js")
1403
1858
  bytes = Buffer.from(bridgeScript(grant.id, grant.origin));
@@ -1417,6 +1872,9 @@ export function createPanelRuntime(options) {
1417
1872
  bytes = Buffer.from(injectBridge(bytes.toString("utf8"), source));
1418
1873
  const resourceOrigin = grant.origin;
1419
1874
  const assetSource = resourceOrigin + source;
1875
+ // Panels export their own generated files (for example JSON backups).
1876
+ // Download permission does not grant origin access, network fetches or popups.
1877
+ // The embedding iframe must enable the same permission.
1420
1878
  response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
1421
1879
  assetSource +
1422
1880
  "; style-src " +
@@ -1427,7 +1885,7 @@ export function createPanelRuntime(options) {
1427
1885
  assetSource +
1428
1886
  " blob:; font-src " +
1429
1887
  assetSource +
1430
- " data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts; frame-ancestors " +
1888
+ " data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads; frame-ancestors " +
1431
1889
  resourceOrigin);
1432
1890
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
1433
1891
  }