@cjhyy/code-shell-server 0.9.19 → 0.9.21

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.
@@ -15,3 +15,4 @@ export * from "./panels/bridge-contract.js";
15
15
  export * from "./panels/connections.js";
16
16
  export * from "./panels/tool-jobs.js";
17
17
  export * from "./panels/tool-executor.js";
18
+ export * from "./panels/directory-bookmarks.js";
@@ -14,3 +14,4 @@ export * from "./panels/bridge-contract.js";
14
14
  export * from "./panels/connections.js";
15
15
  export * from "./panels/tool-jobs.js";
16
16
  export * from "./panels/tool-executor.js";
17
+ export * from "./panels/directory-bookmarks.js";
@@ -11,6 +11,7 @@ export const panelProcessMethods = [
11
11
  "process.end",
12
12
  "filesystem.getKnownDirectory",
13
13
  "filesystem.pickDirectory",
14
+ "filesystem.restoreDirectory",
14
15
  "filesystem.openDirectory",
15
16
  ];
16
17
  export const panelResourceMethods = [
@@ -0,0 +1,7 @@
1
+ /** The guest receives only a random bookmark. A path saved in guest storage grants nothing. */
2
+ export declare class PanelAppDirectoryBookmarks {
3
+ private readonly file;
4
+ constructor(file: string);
5
+ remember(appId: string, projectPath: string, path: string): string;
6
+ restore(appId: string, projectPath: string, id: unknown): string;
7
+ }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute } from "node:path";
4
+ import { acquireFileLock } from "@cjhyy/code-shell-core/internal";
5
+ const MAX_RECORDS = 256;
6
+ const MAX_BYTES = 256 * 1024;
7
+ const ID = /^[a-f0-9-]{36}$/i;
8
+ function valid(record) {
9
+ if (!record || typeof record !== "object" || Array.isArray(record))
10
+ return false;
11
+ const item = record;
12
+ return (typeof item.id === "string" &&
13
+ ID.test(item.id) &&
14
+ typeof item.appId === "string" &&
15
+ /^[a-z][a-z0-9-]{0,63}$/.test(item.appId) &&
16
+ typeof item.projectPath === "string" &&
17
+ isAbsolute(item.projectPath) &&
18
+ typeof item.path === "string" &&
19
+ isAbsolute(item.path) &&
20
+ item.path.length <= 32_768 &&
21
+ item.projectPath.length <= 32_768 &&
22
+ Number.isSafeInteger(item.dev) &&
23
+ Number.isSafeInteger(item.ino) &&
24
+ Number.isSafeInteger(item.savedAt));
25
+ }
26
+ function read(file) {
27
+ let fd;
28
+ try {
29
+ const entry = lstatSync(file);
30
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_BYTES)
31
+ throw new Error("Panel directory bookmarks are not a bounded regular file");
32
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
33
+ const opened = fstatSync(fd);
34
+ if (!opened.isFile() || opened.size > MAX_BYTES)
35
+ throw new Error("Panel directory bookmarks are not a bounded regular file");
36
+ const document = JSON.parse(readFileSync(fd, "utf8"));
37
+ if (!document || typeof document !== "object" || Array.isArray(document))
38
+ throw new Error("Invalid Panel directory bookmarks");
39
+ const value = document;
40
+ if (value.version !== 1 ||
41
+ !Array.isArray(value.bookmarks) ||
42
+ value.bookmarks.length > MAX_RECORDS ||
43
+ !value.bookmarks.every(valid))
44
+ throw new Error("Invalid Panel directory bookmarks");
45
+ return value.bookmarks;
46
+ }
47
+ catch (error) {
48
+ if (error.code === "ENOENT")
49
+ return [];
50
+ throw error;
51
+ }
52
+ finally {
53
+ if (fd !== undefined)
54
+ closeSync(fd);
55
+ }
56
+ }
57
+ function write(file, bookmarks) {
58
+ const parent = dirname(file);
59
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
60
+ const directory = lstatSync(parent);
61
+ if (!directory.isDirectory() || directory.isSymbolicLink())
62
+ throw new Error("Invalid Panel bookmark directory");
63
+ try {
64
+ const existing = lstatSync(file);
65
+ if (!existing.isFile() || existing.isSymbolicLink())
66
+ throw new Error("Invalid Panel bookmark file");
67
+ }
68
+ catch (error) {
69
+ if (error.code !== "ENOENT")
70
+ throw error;
71
+ }
72
+ const serialized = JSON.stringify({ version: 1, bookmarks });
73
+ if (Buffer.byteLength(serialized) > MAX_BYTES)
74
+ throw new Error("Panel directory bookmarks are full");
75
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
76
+ try {
77
+ writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
78
+ renameSync(temporary, file);
79
+ if (process.platform !== "win32")
80
+ chmodSync(file, 0o600);
81
+ }
82
+ finally {
83
+ rmSync(temporary, { force: true });
84
+ }
85
+ }
86
+ /** The guest receives only a random bookmark. A path saved in guest storage grants nothing. */
87
+ export class PanelAppDirectoryBookmarks {
88
+ file;
89
+ constructor(file) {
90
+ this.file = file;
91
+ }
92
+ remember(appId, projectPath, path) {
93
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(appId) || !isAbsolute(projectPath) || !isAbsolute(path))
94
+ throw new Error("Invalid Panel directory bookmark scope");
95
+ const info = lstatSync(path);
96
+ if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path)
97
+ throw new Error("Selected directory changed; choose it again");
98
+ const bookmark = {
99
+ id: randomUUID(),
100
+ appId,
101
+ projectPath,
102
+ path,
103
+ dev: info.dev,
104
+ ino: info.ino,
105
+ savedAt: Date.now(),
106
+ };
107
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
108
+ const release = acquireFileLock(this.file);
109
+ try {
110
+ const previous = read(this.file);
111
+ write(this.file, [
112
+ bookmark,
113
+ ...previous.filter((item) => !(item.appId === appId && item.projectPath === projectPath && item.path === path)),
114
+ ].slice(0, MAX_RECORDS));
115
+ }
116
+ finally {
117
+ release();
118
+ }
119
+ return bookmark.id;
120
+ }
121
+ restore(appId, projectPath, id) {
122
+ if (typeof id !== "string" || !ID.test(id))
123
+ throw new Error("Invalid directory bookmark");
124
+ const bookmark = read(this.file).find((item) => item.id === id && item.appId === appId && item.projectPath === projectPath);
125
+ if (!bookmark)
126
+ throw new Error("Saved directory is unavailable; choose it again");
127
+ try {
128
+ const info = lstatSync(bookmark.path);
129
+ if (!info.isDirectory() ||
130
+ info.isSymbolicLink() ||
131
+ info.dev !== bookmark.dev ||
132
+ info.ino !== bookmark.ino ||
133
+ realpathSync(bookmark.path) !== bookmark.path)
134
+ throw new Error("Saved directory changed");
135
+ }
136
+ catch {
137
+ throw new Error("Saved directory changed; choose it again");
138
+ }
139
+ return bookmark.path;
140
+ }
141
+ }
@@ -25,10 +25,10 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
25
25
  };
26
26
  activeTaskCount: () => number;
27
27
  panelAction: (ownerId: string, sessionId: string, input: Record<string, unknown>) => Promise<unknown>;
28
- invalidate: (appId?: string) => void;
28
+ invalidate: (appId?: string) => Promise<void>;
29
29
  ownsAssets: (request: IncomingMessage) => boolean;
30
30
  handleAssets: (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
31
31
  handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
32
32
  cancelOwner(owner: string): void;
33
- close(): void;
33
+ close(): Promise<void>;
34
34
  };
@@ -6,7 +6,7 @@ export function createPanelHttp(options) {
6
6
  ...options,
7
7
  compatibility: options.compatibility ?? panelWebCompatibility,
8
8
  onChanged: async (id, kind) => {
9
- runtime?.invalidate(id);
9
+ await runtime?.invalidate(id);
10
10
  await options.onChanged?.(id, kind);
11
11
  },
12
12
  });
@@ -29,8 +29,8 @@ export function createPanelHttp(options) {
29
29
  runtime.cancelOwner(owner);
30
30
  management.cancelOwner(owner);
31
31
  },
32
- close() {
33
- runtime.close();
32
+ async close() {
33
+ await runtime.close();
34
34
  management.close();
35
35
  },
36
36
  };
@@ -44,8 +44,8 @@ export declare function createPanelRuntime(options: PanelRuntimeOptions): {
44
44
  panelAction(ownerId: string, sessionId: string, input: Record<string, unknown>): Promise<unknown>;
45
45
  ownsAssets(request: IncomingMessage): boolean;
46
46
  cancelOwner: (owner: string) => void;
47
- invalidate(appId?: string): void;
48
- close(): Promise<undefined>;
47
+ invalidate(appId?: string): Promise<void>;
48
+ close(): Promise<void>;
49
49
  handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
50
50
  handleAssets(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
51
51
  };
@@ -4,10 +4,13 @@ import { lstat, mkdir, open, opendir, realpath } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, extname, join, resolve, sep } from "node:path";
6
6
  import { listInstalledPanelApps, validateToolArgsStrict, } from "@cjhyy/code-shell-core";
7
- import { panelRuntimeApiVersion, panelProcessMethods, panelResourceMethods, panelRuntimeCapabilities, PanelBridgeError, panelBridgeFailure, } from "./bridge-contract.js";
7
+ import { panelRuntimeApiVersion, panelProcessMethods, panelResourceMethods, panelToolJobMethods, panelRuntimeCapabilities, PanelBridgeError, panelBridgeFailure, } from "./bridge-contract.js";
8
8
  import { PanelResourceService } from "./resources/service.js";
9
9
  import { panelConnections, panelConnectionIds, materializePanelConnections, } from "./connections.js";
10
10
  import { PanelRuntimeServices } from "./runtime-services.js";
11
+ import { PanelToolJobService, toolJobLimits, } from "./tool-jobs.js";
12
+ import { createPanelToolExecutor } from "./tool-executor.js";
13
+ import { PanelAppDirectoryBookmarks } from "./directory-bookmarks.js";
11
14
  import { handlePanelProcessDirectory } from "./process-files.js";
12
15
  import { PanelAppProcessService, panelExecutableDirectories, panelProcessInfo, } from "./process-service.js";
13
16
  import { PanelManagementError } from "./management.js";
@@ -29,6 +32,7 @@ const METHODS = [
29
32
  "notifications.send",
30
33
  ...panelProcessMethods,
31
34
  ...panelResourceMethods,
35
+ ...panelToolJobMethods,
32
36
  "credentials.connections.list",
33
37
  "credentials.connections.authorizeProcess",
34
38
  "agent.task.models",
@@ -244,7 +248,7 @@ function bridgeScript(id, origin) {
244
248
  const requestId = String(++next);
245
249
  const timer = setTimeout(() => {
246
250
  pending.delete(requestId); reject(new Error("Panel request timed out"));
247
- }, 60000);
251
+ }, method === "tasks.start" ? 30 * 60 * 1000 : 60000);
248
252
  pending.set(requestId, { resolve, reject, timer });
249
253
  try {
250
254
  parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
@@ -288,7 +292,7 @@ function bridgeScript(id, origin) {
288
292
  call: ask,
289
293
  callResult: (method, params) => ask(method, params).then(value => ({ ok: true, value }), error => ({ ok: false, error: { code: error.code || "OPERATION_FAILED", message: error.message, ...(typeof error.retryAfterMs === "number" ? { retryAfterMs: error.retryAfterMs } : {}) } })),
290
294
  on: (name, listener) => {
291
- if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
295
+ if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "tasks.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
292
296
  throw new Error("Invalid event listener");
293
297
  }
294
298
  let list = listeners.get(name);
@@ -364,10 +368,36 @@ export function createPanelRuntime(options) {
364
368
  let closed = false;
365
369
  let generation = 0;
366
370
  let nextGuest = 0;
371
+ let nextToolOwner = -1;
367
372
  const byGuest = new Map();
373
+ const directoryBookmarks = new PanelAppDirectoryBookmarks(join(options.dataDir, "panel-web-directory-bookmarks.json"));
374
+ const panelDataDirectory = (appId) => join(options.dataDir, "panel-data", appId, digest(Buffer.from(options.bindingCwd ?? options.cwd)).slice(0, 24));
375
+ const toolOwners = new Map();
376
+ const jobOwners = new Map();
377
+ let toolJobs;
378
+ async function installedToolApp(scope) {
379
+ if (closed || scope.projectPath !== (options.bindingCwd ?? options.cwd))
380
+ 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);
383
+ if (!panel?.enabled ||
384
+ panel.revision !== scope.revision ||
385
+ !app?.permissions.includes("process") ||
386
+ !app.permissions.includes("resources"))
387
+ throw new PanelBridgeError("REVOKED", "Installed tool task is no longer authorized");
388
+ return app;
389
+ }
368
390
  const processes = new PanelAppProcessService({
369
391
  approvalScope: "guest",
370
392
  resolvePackageEntry: async (owner, name) => {
393
+ const taskScope = toolOwners.get(owner.guestId);
394
+ if (taskScope) {
395
+ const app = await installedToolApp(taskScope);
396
+ const entry = app.nativeEntries?.[name];
397
+ if (!entry)
398
+ throw new PanelBridgeError("REVOKED", "Installed tool entry is unavailable");
399
+ return { path: join(app.installPath, entry.entry), sha256: entry.sha256 };
400
+ }
371
401
  const grant = byGuest.get(owner.guestId);
372
402
  const entry = grant?.app.nativeEntries?.[name];
373
403
  if (!grant ||
@@ -379,10 +409,15 @@ export function createPanelRuntime(options) {
379
409
  },
380
410
  extraPathDirectories: () => panelExecutableDirectories(join(options.dataDir, "panel-bin"), { home: homedir() }),
381
411
  isOwnerAuthorized: async (owner) => {
412
+ const taskScope = toolOwners.get(owner.guestId);
413
+ if (taskScope)
414
+ return !!(await installedToolApp(taskScope).catch(() => null));
382
415
  const grant = byGuest.get(owner.guestId);
383
416
  return !!grant && (await authorized(grant));
384
417
  },
385
418
  confirmExecution: async (input) => {
419
+ if (toolOwners.has(input.guestId))
420
+ return true;
386
421
  const grant = byGuest.get(input.guestId);
387
422
  return (!!grant &&
388
423
  (await confirm(grant, `允许 ${input.appTitle} 运行 ${input.executable}?`, `程序将在服务端运行,可使用当前运行用户的文件与网络权限。\n程序:${input.executablePath}`)));
@@ -391,6 +426,11 @@ export function createPanelRuntime(options) {
391
426
  const resources = new PanelResourceService({
392
427
  rootDirectory: join(options.dataDir, "panel-app-media"),
393
428
  isScopeAuthorized: async (scope) => {
429
+ if (scope.projectPath === (options.bindingCwd ?? options.cwd)) {
430
+ const panel = (await snapshot()).panels.find((candidate) => candidate.id === scope.appId);
431
+ if (panel?.enabled && panel.permissions.includes("resources"))
432
+ return true;
433
+ }
394
434
  for (const grant of grants.values())
395
435
  if (grant.app.id === scope.appId &&
396
436
  scope.projectPath === (options.bindingCwd ?? options.cwd) &&
@@ -407,6 +447,156 @@ export function createPanelRuntime(options) {
407
447
  }, 60_000);
408
448
  reaper.unref();
409
449
  const preparing = new Set();
450
+ const preparingTools = new Set();
451
+ async function dispatchToolJobs(grant, method, params) {
452
+ if (!grant.app.permissions.includes("process") || !grant.app.permissions.includes("resources"))
453
+ error(403, "原生后台任务需要 process 和 resources 权限。");
454
+ const scope = {
455
+ appId: grant.app.id,
456
+ projectPath: options.bindingCwd ?? options.cwd,
457
+ revision: grant.revision,
458
+ };
459
+ const input = (params ?? {});
460
+ const service = getToolJobs();
461
+ if (method === "tasks.start") {
462
+ const app = await installedToolApp(scope);
463
+ const entry = app.nativeEntries?.[input.entry ?? ""];
464
+ if (!entry)
465
+ error(501, "安装的原生工具不可用。");
466
+ if (!(await confirm(grant, `启动 ${grant.app.title.default} 的后台工具?`, input.entry)))
467
+ error(403, "你取消了后台工具执行。");
468
+ if (!(await authorized(grant)))
469
+ error(410, "面板授权已失效。");
470
+ const preparation = {
471
+ owner: grant.owner,
472
+ appId: scope.appId,
473
+ controller: new AbortController(),
474
+ };
475
+ preparingTools.add(preparation);
476
+ try {
477
+ const job = await service.start(scope, {
478
+ entry: { name: input.entry, sha256: entry.sha256 },
479
+ input: input.input,
480
+ recovery: input.recovery ?? "manual",
481
+ requestKey: input.requestKey,
482
+ }, preparation.controller.signal);
483
+ if (!jobOwners.has(job.id))
484
+ jobOwners.set(job.id, { owner: grant.owner, scope });
485
+ if (!(await authorized(grant))) {
486
+ if (jobOwners.get(job.id)?.owner === grant.owner)
487
+ await service.cancel(scope, job.id);
488
+ error(410, "登录授权已撤销。");
489
+ }
490
+ emitTaskEvent(await service.get(scope, job.id));
491
+ return job;
492
+ }
493
+ catch (cause) {
494
+ if (preparation.controller.signal.aborted)
495
+ error(410, "登录授权已撤销,输入准备已取消。");
496
+ throw cause;
497
+ }
498
+ finally {
499
+ preparingTools.delete(preparation);
500
+ }
501
+ }
502
+ if (method === "tasks.list") {
503
+ const offset = input.offset ?? 0, limit = input.limit ?? 50;
504
+ if (!Number.isSafeInteger(offset) ||
505
+ offset < 0 ||
506
+ !Number.isSafeInteger(limit) ||
507
+ limit < 1 ||
508
+ limit > 50)
509
+ throw new PanelBridgeError("INVALID_ARGUMENT", "Invalid task page");
510
+ return (await service.list(scope))
511
+ .slice(offset, offset + limit)
512
+ .map((job) => toolSummary(job));
513
+ }
514
+ if (method === "tasks.get")
515
+ return service.get(scope, input.id);
516
+ if (method === "tasks.cancel")
517
+ return service.cancel(scope, input.id);
518
+ if (method === "tasks.retry") {
519
+ if (!(await confirm(grant, `重试 ${grant.app.title.default} 的后台工具?`, input.id ?? "")))
520
+ error(403, "你取消了后台工具重试。");
521
+ if (!(await authorized(grant)))
522
+ error(410, "面板授权已失效。");
523
+ const job = await service.retry(scope, input.id);
524
+ jobOwners.set(job.id, { owner: grant.owner, scope });
525
+ if (!(await authorized(grant))) {
526
+ await service.cancel(scope, job.id);
527
+ error(410, "登录授权已撤销,后台工具已取消。");
528
+ }
529
+ emitTaskEvent(await service.get(scope, job.id));
530
+ return job;
531
+ }
532
+ throw new PanelBridgeError("NOT_SUPPORTED", "Unknown tool task operation");
533
+ }
534
+ function toolSummary(job) {
535
+ const { input: _input, result: _result, ...summary } = job;
536
+ return summary;
537
+ }
538
+ function emitTaskEvent(job) {
539
+ const owner = jobOwners.get(job.id)?.owner;
540
+ if (!owner)
541
+ return;
542
+ for (const grant of grants.values())
543
+ if (grant.owner === owner &&
544
+ grant.app.id === job.scope.appId &&
545
+ grant.revision === job.scope.revision &&
546
+ grant.app.permissions.includes("process") &&
547
+ grant.app.permissions.includes("resources"))
548
+ emit(grant, "tasks.changed", toolSummary(job));
549
+ if (["succeeded", "failed", "cancelled", "interrupted"].includes(job.status))
550
+ jobOwners.delete(job.id);
551
+ }
552
+ function getToolJobs() {
553
+ if (toolJobs)
554
+ return toolJobs;
555
+ const executor = createPanelToolExecutor({
556
+ processes,
557
+ resources,
558
+ owner: (job, send) => {
559
+ const guestId = nextToolOwner--;
560
+ toolOwners.set(guestId, job.scope);
561
+ return {
562
+ guestId,
563
+ appId: job.scope.appId,
564
+ appTitle: job.scope.appId,
565
+ revision: job.scope.revision,
566
+ send,
567
+ };
568
+ },
569
+ releaseOwner: (owner) => {
570
+ toolOwners.delete(owner.guestId);
571
+ },
572
+ authorize: async (scope) => {
573
+ await installedToolApp(scope);
574
+ },
575
+ authorizeConnections: async (scope) => {
576
+ const app = await installedToolApp(scope);
577
+ if (!app.permissions.includes("credentials.connections"))
578
+ throw new PanelBridgeError("PERMISSION_DENIED", "Tool requires connection permission");
579
+ },
580
+ appDataDirectory: async (scope) => {
581
+ await installedToolApp(scope);
582
+ const path = panelDataDirectory(scope.appId);
583
+ await mkdir(path, { recursive: true, mode: 0o700 });
584
+ return path;
585
+ },
586
+ sealedRoot: join(options.dataDir, "panel-app-sealed"),
587
+ });
588
+ 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"),
594
+ ...executor,
595
+ isAuthorized: async (scope) => !!(await installedToolApp(scope).catch(() => null)),
596
+ onEvent: emitTaskEvent,
597
+ });
598
+ return toolJobs;
599
+ }
410
600
  let readingSnapshot;
411
601
  function snapshot() {
412
602
  // Parallel module requests share current disk work, without a stale TTL
@@ -570,12 +760,18 @@ export function createPanelRuntime(options) {
570
760
  };
571
761
  }
572
762
  function cancelOwner(owner) {
763
+ for (const preparation of preparingTools)
764
+ if (preparation.owner === owner)
765
+ preparation.controller.abort();
573
766
  for (const preparation of preparing)
574
767
  if (preparation.owner === owner)
575
768
  preparation.cancelled = true;
576
769
  for (const grant of grants.values())
577
770
  if (grant.owner === owner)
578
771
  remove(grant);
772
+ for (const [id, record] of jobOwners)
773
+ if (record.owner === owner)
774
+ void toolJobs?.cancel(record.scope, id).catch(() => { });
579
775
  }
580
776
  async function authorized(grant) {
581
777
  if (closed ||
@@ -661,23 +857,48 @@ export function createPanelRuntime(options) {
661
857
  theme: input.theme === "dark" ? "dark" : "light",
662
858
  locale: typeof input.locale === "string" ? input.locale.slice(0, 32) : "zh-CN",
663
859
  apiVersion: panelRuntimeApiVersion,
664
- capabilities: panelRuntimeCapabilities({
665
- process: app.permissions.includes("process"),
666
- resources: app.permissions.includes("resources") ? resources.capabilities() : undefined,
667
- limits: {
668
- maxParamsBytes: 3 * 1024 * 1024,
669
- maxResultBytes: 3 * 1024 * 1024,
670
- rateWindowMs: 60000,
671
- maxCallsPerWindow: 240,
672
- maxTransferCallsPerWindow: 2048,
673
- callTimeoutMs: 60000,
674
- consentTimeoutMs: 50000,
675
- },
676
- }),
860
+ capabilities: {
861
+ ...panelRuntimeCapabilities({
862
+ process: app.permissions.includes("process"),
863
+ resources: app.permissions.includes("resources") ? resources.capabilities() : undefined,
864
+ tasks: app.permissions.includes("process") && app.permissions.includes("resources")
865
+ ? {
866
+ available: true,
867
+ ...toolJobLimits,
868
+ maxHttpResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
869
+ }
870
+ : undefined,
871
+ limits: {
872
+ maxParamsBytes: 3 * 1024 * 1024,
873
+ maxResultBytes: 3 * 1024 * 1024,
874
+ rateWindowMs: 60000,
875
+ maxCallsPerWindow: 240,
876
+ maxTransferCallsPerWindow: 2048,
877
+ callTimeoutMs: 60000,
878
+ consentTimeoutMs: 50000,
879
+ },
880
+ }),
881
+ ...(app.permissions.includes("process") && app.permissions.includes("resources")
882
+ ? {
883
+ 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 },
892
+ },
893
+ }
894
+ : {}),
895
+ },
677
896
  host: options.host,
678
897
  availableMethods: METHODS.filter((method) => {
679
898
  if (method.startsWith("agent.task.") && !agentTasks)
680
899
  return false;
900
+ if (method.startsWith("tasks."))
901
+ return app.permissions.includes("process") && app.permissions.includes("resources");
681
902
  if ([
682
903
  "resources.materialize",
683
904
  "resources.capture",
@@ -767,6 +988,8 @@ export function createPanelRuntime(options) {
767
988
  grant.calls = history;
768
989
  if (method === "context.get")
769
990
  return grant.context;
991
+ if (method.startsWith("tasks."))
992
+ return dispatchToolJobs(grant, method, params);
770
993
  if (method.startsWith("tools.")) {
771
994
  const name = params?.name;
772
995
  if (typeof name !== "string" || !grant.app.agent?.tools.some((tool) => tool.name === name))
@@ -865,23 +1088,40 @@ export function createPanelRuntime(options) {
865
1088
  url: `${ROOT}${grant.id}/directory/${handle}`,
866
1089
  };
867
1090
  }
1091
+ if (method === "filesystem.restoreDirectory") {
1092
+ const saved = directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1093
+ if (saved !== (await realpath(join(options.cwd, "downloads"))))
1094
+ throw new PanelBridgeError("PERMISSION_DENIED", "Saved server directory is unavailable");
1095
+ const restored = await processes.grantDirectory(owner, saved);
1096
+ directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1097
+ return { ...restored, bookmark: params.bookmark };
1098
+ }
868
1099
  const name = method === "filesystem.pickDirectory" ? "downloads" : params?.name;
869
- const directory = name === "downloads"
870
- ? join(options.cwd, "downloads")
871
- : name === "user-bin"
872
- ? join(options.dataDir, "panel-bin")
873
- : name === "app-data"
874
- ? join(options.dataDir, "panel-data", grant.app.id, digest(Buffer.from(options.bindingCwd ?? options.cwd)).slice(0, 24))
875
- : undefined;
1100
+ const directory = name === "project"
1101
+ ? options.cwd
1102
+ : name === "downloads"
1103
+ ? join(options.cwd, "downloads")
1104
+ : name === "user-bin"
1105
+ ? join(options.dataDir, "panel-bin")
1106
+ : name === "app-data"
1107
+ ? panelDataDirectory(grant.app.id)
1108
+ : undefined;
876
1109
  if (!directory)
877
1110
  error(400, "不支持这个服务端目录。");
878
1111
  if (method === "filesystem.pickDirectory" &&
879
1112
  !(await confirm(grant, "使用服务端下载目录?", directory)))
880
1113
  return null;
881
- await mkdir(directory, { recursive: true, mode: 0o700 });
1114
+ if (name !== "project")
1115
+ await mkdir(directory, { recursive: true, mode: 0o700 });
882
1116
  if (!(await authorized(grant)))
883
1117
  error(410, "面板授权已失效。");
884
- return processes.grantDirectory(owner, directory);
1118
+ const selected = await processes.grantDirectory(owner, directory);
1119
+ if (method !== "filesystem.pickDirectory")
1120
+ return selected;
1121
+ const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
1122
+ if (!(await authorized(grant)))
1123
+ error(410, "面板授权已失效。");
1124
+ return { ...selected, bookmark };
885
1125
  }
886
1126
  if (["external.open", "agent.submitPrompt", "notifications.send"].includes(method)) {
887
1127
  if (!grant.app.permissions.includes(method))
@@ -935,7 +1175,7 @@ export function createPanelRuntime(options) {
935
1175
  }, method, params);
936
1176
  }
937
1177
  return {
938
- activeTaskCount: () => agentTasks?.activeTaskCount?.() ?? 0,
1178
+ activeTaskCount: () => (agentTasks?.activeTaskCount?.() ?? 0) + (toolJobs?.activeCount() ?? 0),
939
1179
  async panelAction(ownerId, sessionId, input) {
940
1180
  const candidates = [];
941
1181
  for (const grant of grants.values()) {
@@ -967,20 +1207,36 @@ export function createPanelRuntime(options) {
967
1207
  cancelOwner,
968
1208
  invalidate(appId) {
969
1209
  generation++;
1210
+ for (const preparation of preparingTools)
1211
+ if (!appId || preparation.appId === appId)
1212
+ preparation.controller.abort();
970
1213
  for (const grant of grants.values())
971
1214
  if (!appId || grant.app.id === appId)
972
1215
  remove(grant);
1216
+ if (!toolJobs)
1217
+ return Promise.resolve();
1218
+ const appIds = appId
1219
+ ? [appId]
1220
+ : [...new Set([...jobOwners.values()].map((record) => record.scope.appId))];
1221
+ return Promise.all(appIds.map((id) => toolJobs.cancelApp(id))).then(() => undefined);
973
1222
  },
974
- close() {
1223
+ async close() {
975
1224
  closed = true;
1225
+ for (const preparation of preparingTools)
1226
+ preparation.controller.abort();
976
1227
  clearInterval(reaper);
977
1228
  for (const grant of grants.values())
978
1229
  remove(grant);
979
- processes.close();
980
1230
  assets.clear();
981
1231
  for (const preparation of preparing)
982
1232
  preparation.cancelled = true;
983
- return Promise.all([resources.shutdown(), Promise.resolve(agentTasks?.close())]).then(() => undefined);
1233
+ try {
1234
+ await toolJobs?.shutdown();
1235
+ }
1236
+ finally {
1237
+ processes.close();
1238
+ await Promise.all([resources.shutdown(), Promise.resolve(agentTasks?.close())]);
1239
+ }
984
1240
  },
985
1241
  async handle(request, response) {
986
1242
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -1075,10 +1331,14 @@ export function createPanelRuntime(options) {
1075
1331
  json(response, 200, { accepted: true });
1076
1332
  }
1077
1333
  else if (request.method === "POST" && match[2] === "call") {
1078
- const value = await call(grant, await body(request));
1334
+ const input = await body(request);
1335
+ const value = await call(grant, input);
1079
1336
  if (!(await authorized(grant)))
1080
1337
  error(410, "面板授权已失效,请重新打开。");
1081
- if (Buffer.byteLength(JSON.stringify(value) ?? "null") > 3 * 1024 * 1024)
1338
+ const resultLimit = typeof input.method === "string" && input.method.startsWith("tasks.")
1339
+ ? toolJobLimits.maxRecordBytes + 128 * 1024
1340
+ : 3 * 1024 * 1024;
1341
+ if (Buffer.byteLength(JSON.stringify(value) ?? "null") > resultLimit)
1082
1342
  throw new PanelBridgeError("RESULT_TOO_LARGE", "Panel result exceeds the response limit");
1083
1343
  json(response, 200, value);
1084
1344
  }
@@ -78,6 +78,7 @@ export declare class PanelToolJobService {
78
78
  private pumping;
79
79
  private shutdownPromise?;
80
80
  constructor(options: PanelToolJobServiceOptions);
81
+ activeCount(): number;
81
82
  private now;
82
83
  private exclusive;
83
84
  private authorize;
@@ -91,7 +92,7 @@ export declare class PanelToolJobService {
91
92
  get(rawScope: ToolJobScope, id: string): Promise<ToolJob & {
92
93
  readOnly: boolean;
93
94
  }>;
94
- start(rawScope: ToolJobScope, request: ToolJobRequest): Promise<ToolJob>;
95
+ start(rawScope: ToolJobScope, request: ToolJobRequest, signal?: AbortSignal): Promise<ToolJob>;
95
96
  private schedule;
96
97
  private pump;
97
98
  private run;
@@ -110,6 +110,10 @@ export class PanelToolJobService {
110
110
  this.options = options;
111
111
  this.storage = new ToolJobStorage(options.rootDir);
112
112
  }
113
+ activeCount() {
114
+ return (this.preparing.size +
115
+ [...this.jobs.values()].filter((job) => !TERMINAL.has(job.status)).length);
116
+ }
113
117
  now() {
114
118
  return this.options.now?.() ?? Date.now();
115
119
  }
@@ -204,7 +208,8 @@ export class PanelToolJobService {
204
208
  const job = this.lookup(scope, id);
205
209
  return { ...publicJob(job), readOnly: job.scope.revision !== scope.revision };
206
210
  }
207
- async start(rawScope, request) {
211
+ async start(rawScope, request, signal) {
212
+ signal?.throwIfAborted();
208
213
  const scope = scopeValue(rawScope);
209
214
  const entry = entryValue(request?.entry);
210
215
  if (!["manual", "retry"].includes(request.recovery))
@@ -228,6 +233,7 @@ export class PanelToolJobService {
228
233
  await this.authorize(scope);
229
234
  let done;
230
235
  const reservation = await this.exclusive(async () => {
236
+ signal?.throwIfAborted();
231
237
  if (this.stopping)
232
238
  throw new Error("tool job service is shutting down");
233
239
  if (request.requestKey) {
@@ -264,8 +270,14 @@ export class PanelToolJobService {
264
270
  if (reservation.existing)
265
271
  return publicJob(reservation.existing);
266
272
  const { id, controller } = reservation;
273
+ const abort = () => controller.abort(signal?.reason);
274
+ signal?.addEventListener("abort", abort, { once: true });
275
+ if (signal?.aborted)
276
+ abort();
267
277
  try {
278
+ controller.signal.throwIfAborted();
268
279
  const workDir = await this.storage.directory(id, true, true);
280
+ controller.signal.throwIfAborted();
269
281
  const prepared = this.options.prepareInput
270
282
  ? await this.options.prepareInput(scope, input, workDir, controller.signal)
271
283
  : input;
@@ -292,6 +304,19 @@ export class PanelToolJobService {
292
304
  [...this.jobs.values()].some((other) => sameScope(other.scope, scope) && other.requestKey === request.requestKey))
293
305
  throw new Error("tool job request key is already being prepared");
294
306
  await this.commit(job);
307
+ // Cancellation can arrive during the durable write. The serial fence
308
+ // prevents the scheduler from selecting this job before cancellation.
309
+ if (controller.signal.aborted) {
310
+ job.status = "cancelled";
311
+ job.completedAt = this.now();
312
+ job.error = {
313
+ code: "CANCELLED",
314
+ message: "Task was cancelled before execution.",
315
+ retryable: job.recovery === "retry",
316
+ };
317
+ await this.commit(job);
318
+ controller.signal.throwIfAborted();
319
+ }
295
320
  });
296
321
  this.schedule();
297
322
  return publicJob(job);
@@ -303,6 +328,7 @@ export class PanelToolJobService {
303
328
  throw error;
304
329
  }
305
330
  finally {
331
+ signal?.removeEventListener("abort", abort);
306
332
  this.preparing.delete(id);
307
333
  done();
308
334
  }
@@ -917,7 +917,7 @@ export async function startHeadlessServer(opts) {
917
917
  await mcp?.close();
918
918
  configuration?.close();
919
919
  links?.close();
920
- panels?.close();
920
+ await panels?.close();
921
921
  files?.close();
922
922
  bridge.kill();
923
923
  wss.close();
@@ -947,7 +947,7 @@ export async function startHeadlessServer(opts) {
947
947
  await mcp?.close();
948
948
  configuration?.close();
949
949
  links?.close();
950
- panels?.close();
950
+ await panels?.close();
951
951
  files?.close();
952
952
  for (const tab of tabs) {
953
953
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,13 +51,13 @@
51
51
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
52
52
  },
53
53
  "dependencies": {
54
- "@cjhyy/code-shell-capability-coding": "0.9.19",
55
- "@cjhyy/code-shell-core": "0.9.19",
56
- "@cjhyy/code-shell-web": "0.9.19",
54
+ "@cjhyy/code-shell-capability-coding": "0.9.21",
55
+ "@cjhyy/code-shell-core": "0.9.21",
56
+ "@cjhyy/code-shell-web": "0.9.21",
57
57
  "@modelcontextprotocol/sdk": "^1.12.1",
58
58
  "@types/ws": "^8.18.1",
59
59
  "ws": "^8.21.0",
60
- "@cjhyy/code-shell-link": "0.9.19"
60
+ "@cjhyy/code-shell-link": "0.9.21"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=20.10"