@cjhyy/code-shell-server 0.9.19 → 0.9.20

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,139 @@ export function createPanelRuntime(options) {
407
447
  }, 60_000);
408
448
  reaper.unref();
409
449
  const preparing = new Set();
450
+ async function dispatchToolJobs(grant, method, params) {
451
+ if (!grant.app.permissions.includes("process") || !grant.app.permissions.includes("resources"))
452
+ error(403, "原生后台任务需要 process 和 resources 权限。");
453
+ const scope = {
454
+ appId: grant.app.id,
455
+ projectPath: options.bindingCwd ?? options.cwd,
456
+ revision: grant.revision,
457
+ };
458
+ const input = (params ?? {});
459
+ const service = getToolJobs();
460
+ if (method === "tasks.start") {
461
+ const app = await installedToolApp(scope);
462
+ const entry = app.nativeEntries?.[input.entry ?? ""];
463
+ if (!entry)
464
+ error(501, "安装的原生工具不可用。");
465
+ if (!(await confirm(grant, `启动 ${grant.app.title.default} 的后台工具?`, input.entry)))
466
+ error(403, "你取消了后台工具执行。");
467
+ if (!(await authorized(grant)))
468
+ error(410, "面板授权已失效。");
469
+ const job = await service.start(scope, {
470
+ entry: { name: input.entry, sha256: entry.sha256 },
471
+ input: input.input,
472
+ recovery: input.recovery ?? "manual",
473
+ requestKey: input.requestKey,
474
+ });
475
+ if (!jobOwners.has(job.id))
476
+ jobOwners.set(job.id, { owner: grant.owner, scope });
477
+ if (!(await authorized(grant))) {
478
+ if (jobOwners.get(job.id)?.owner === grant.owner)
479
+ await service.cancel(scope, job.id);
480
+ error(410, "登录授权已撤销。");
481
+ }
482
+ emitTaskEvent(await service.get(scope, job.id));
483
+ return job;
484
+ }
485
+ if (method === "tasks.list") {
486
+ const offset = input.offset ?? 0, limit = input.limit ?? 50;
487
+ if (!Number.isSafeInteger(offset) ||
488
+ offset < 0 ||
489
+ !Number.isSafeInteger(limit) ||
490
+ limit < 1 ||
491
+ limit > 50)
492
+ throw new PanelBridgeError("INVALID_ARGUMENT", "Invalid task page");
493
+ return (await service.list(scope))
494
+ .slice(offset, offset + limit)
495
+ .map((job) => toolSummary(job));
496
+ }
497
+ if (method === "tasks.get")
498
+ return service.get(scope, input.id);
499
+ if (method === "tasks.cancel")
500
+ return service.cancel(scope, input.id);
501
+ if (method === "tasks.retry") {
502
+ if (!(await confirm(grant, `重试 ${grant.app.title.default} 的后台工具?`, input.id ?? "")))
503
+ error(403, "你取消了后台工具重试。");
504
+ if (!(await authorized(grant)))
505
+ error(410, "面板授权已失效。");
506
+ const job = await service.retry(scope, input.id);
507
+ jobOwners.set(job.id, { owner: grant.owner, scope });
508
+ if (!(await authorized(grant))) {
509
+ await service.cancel(scope, job.id);
510
+ error(410, "登录授权已撤销,后台工具已取消。");
511
+ }
512
+ emitTaskEvent(await service.get(scope, job.id));
513
+ return job;
514
+ }
515
+ throw new PanelBridgeError("NOT_SUPPORTED", "Unknown tool task operation");
516
+ }
517
+ function toolSummary(job) {
518
+ const { input: _input, result: _result, ...summary } = job;
519
+ return summary;
520
+ }
521
+ function emitTaskEvent(job) {
522
+ const owner = jobOwners.get(job.id)?.owner;
523
+ if (!owner)
524
+ return;
525
+ for (const grant of grants.values())
526
+ if (grant.owner === owner &&
527
+ grant.app.id === job.scope.appId &&
528
+ grant.revision === job.scope.revision &&
529
+ grant.app.permissions.includes("process") &&
530
+ grant.app.permissions.includes("resources"))
531
+ emit(grant, "tasks.changed", toolSummary(job));
532
+ if (["succeeded", "failed", "cancelled", "interrupted"].includes(job.status))
533
+ jobOwners.delete(job.id);
534
+ }
535
+ function getToolJobs() {
536
+ if (toolJobs)
537
+ return toolJobs;
538
+ const executor = createPanelToolExecutor({
539
+ processes,
540
+ resources,
541
+ owner: (job, send) => {
542
+ const guestId = nextToolOwner--;
543
+ toolOwners.set(guestId, job.scope);
544
+ return {
545
+ guestId,
546
+ appId: job.scope.appId,
547
+ appTitle: job.scope.appId,
548
+ revision: job.scope.revision,
549
+ send,
550
+ };
551
+ },
552
+ releaseOwner: (owner) => {
553
+ toolOwners.delete(owner.guestId);
554
+ },
555
+ authorize: async (scope) => {
556
+ await installedToolApp(scope);
557
+ },
558
+ authorizeConnections: async (scope) => {
559
+ const app = await installedToolApp(scope);
560
+ if (!app.permissions.includes("credentials.connections"))
561
+ throw new PanelBridgeError("PERMISSION_DENIED", "Tool requires connection permission");
562
+ },
563
+ appDataDirectory: async (scope) => {
564
+ await installedToolApp(scope);
565
+ const path = panelDataDirectory(scope.appId);
566
+ await mkdir(path, { recursive: true, mode: 0o700 });
567
+ return path;
568
+ },
569
+ sealedRoot: join(options.dataDir, "panel-app-sealed"),
570
+ });
571
+ toolJobs = new PanelToolJobService({
572
+ // Desktop's IPC bridge has its own in-memory coordinator for panel-tool-jobs.
573
+ // A Desktop Web workspace must not open the same store concurrently.
574
+ rootDir: options.host === "desktop"
575
+ ? join(options.dataDir, "panel-web-tool-jobs", digest(Buffer.from(options.cwd)).slice(0, 24))
576
+ : join(options.dataDir, "panel-tool-jobs"),
577
+ ...executor,
578
+ isAuthorized: async (scope) => !!(await installedToolApp(scope).catch(() => null)),
579
+ onEvent: emitTaskEvent,
580
+ });
581
+ return toolJobs;
582
+ }
410
583
  let readingSnapshot;
411
584
  function snapshot() {
412
585
  // Parallel module requests share current disk work, without a stale TTL
@@ -576,6 +749,9 @@ export function createPanelRuntime(options) {
576
749
  for (const grant of grants.values())
577
750
  if (grant.owner === owner)
578
751
  remove(grant);
752
+ for (const [id, record] of jobOwners)
753
+ if (record.owner === owner)
754
+ void toolJobs?.cancel(record.scope, id).catch(() => { });
579
755
  }
580
756
  async function authorized(grant) {
581
757
  if (closed ||
@@ -661,23 +837,48 @@ export function createPanelRuntime(options) {
661
837
  theme: input.theme === "dark" ? "dark" : "light",
662
838
  locale: typeof input.locale === "string" ? input.locale.slice(0, 32) : "zh-CN",
663
839
  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
- }),
840
+ capabilities: {
841
+ ...panelRuntimeCapabilities({
842
+ process: app.permissions.includes("process"),
843
+ resources: app.permissions.includes("resources") ? resources.capabilities() : undefined,
844
+ tasks: app.permissions.includes("process") && app.permissions.includes("resources")
845
+ ? {
846
+ available: true,
847
+ ...toolJobLimits,
848
+ maxHttpResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
849
+ }
850
+ : undefined,
851
+ limits: {
852
+ maxParamsBytes: 3 * 1024 * 1024,
853
+ maxResultBytes: 3 * 1024 * 1024,
854
+ rateWindowMs: 60000,
855
+ maxCallsPerWindow: 240,
856
+ maxTransferCallsPerWindow: 2048,
857
+ callTimeoutMs: 60000,
858
+ consentTimeoutMs: 50000,
859
+ },
860
+ }),
861
+ ...(app.permissions.includes("process") && app.permissions.includes("resources")
862
+ ? {
863
+ methodLimits: {
864
+ "tasks.start": {
865
+ maxParamsBytes: toolJobLimits.maxInputBytes,
866
+ maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024,
867
+ timeoutMs: 30 * 60 * 1000,
868
+ },
869
+ "tasks.get": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
870
+ "tasks.retry": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
871
+ "tasks.cancel": { maxResultBytes: toolJobLimits.maxRecordBytes + 128 * 1024 },
872
+ },
873
+ }
874
+ : {}),
875
+ },
677
876
  host: options.host,
678
877
  availableMethods: METHODS.filter((method) => {
679
878
  if (method.startsWith("agent.task.") && !agentTasks)
680
879
  return false;
880
+ if (method.startsWith("tasks."))
881
+ return app.permissions.includes("process") && app.permissions.includes("resources");
681
882
  if ([
682
883
  "resources.materialize",
683
884
  "resources.capture",
@@ -767,6 +968,8 @@ export function createPanelRuntime(options) {
767
968
  grant.calls = history;
768
969
  if (method === "context.get")
769
970
  return grant.context;
971
+ if (method.startsWith("tasks."))
972
+ return dispatchToolJobs(grant, method, params);
770
973
  if (method.startsWith("tools.")) {
771
974
  const name = params?.name;
772
975
  if (typeof name !== "string" || !grant.app.agent?.tools.some((tool) => tool.name === name))
@@ -865,23 +1068,40 @@ export function createPanelRuntime(options) {
865
1068
  url: `${ROOT}${grant.id}/directory/${handle}`,
866
1069
  };
867
1070
  }
1071
+ if (method === "filesystem.restoreDirectory") {
1072
+ const saved = directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1073
+ if (saved !== (await realpath(join(options.cwd, "downloads"))))
1074
+ throw new PanelBridgeError("PERMISSION_DENIED", "Saved server directory is unavailable");
1075
+ const restored = await processes.grantDirectory(owner, saved);
1076
+ directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1077
+ return { ...restored, bookmark: params.bookmark };
1078
+ }
868
1079
  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;
1080
+ const directory = name === "project"
1081
+ ? options.cwd
1082
+ : name === "downloads"
1083
+ ? join(options.cwd, "downloads")
1084
+ : name === "user-bin"
1085
+ ? join(options.dataDir, "panel-bin")
1086
+ : name === "app-data"
1087
+ ? panelDataDirectory(grant.app.id)
1088
+ : undefined;
876
1089
  if (!directory)
877
1090
  error(400, "不支持这个服务端目录。");
878
1091
  if (method === "filesystem.pickDirectory" &&
879
1092
  !(await confirm(grant, "使用服务端下载目录?", directory)))
880
1093
  return null;
881
- await mkdir(directory, { recursive: true, mode: 0o700 });
1094
+ if (name !== "project")
1095
+ await mkdir(directory, { recursive: true, mode: 0o700 });
882
1096
  if (!(await authorized(grant)))
883
1097
  error(410, "面板授权已失效。");
884
- return processes.grantDirectory(owner, directory);
1098
+ const selected = await processes.grantDirectory(owner, directory);
1099
+ if (method !== "filesystem.pickDirectory")
1100
+ return selected;
1101
+ const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
1102
+ if (!(await authorized(grant)))
1103
+ error(410, "面板授权已失效。");
1104
+ return { ...selected, bookmark };
885
1105
  }
886
1106
  if (["external.open", "agent.submitPrompt", "notifications.send"].includes(method)) {
887
1107
  if (!grant.app.permissions.includes(method))
@@ -935,7 +1155,7 @@ export function createPanelRuntime(options) {
935
1155
  }, method, params);
936
1156
  }
937
1157
  return {
938
- activeTaskCount: () => agentTasks?.activeTaskCount?.() ?? 0,
1158
+ activeTaskCount: () => (agentTasks?.activeTaskCount?.() ?? 0) + (toolJobs?.activeCount() ?? 0),
939
1159
  async panelAction(ownerId, sessionId, input) {
940
1160
  const candidates = [];
941
1161
  for (const grant of grants.values()) {
@@ -970,17 +1190,28 @@ export function createPanelRuntime(options) {
970
1190
  for (const grant of grants.values())
971
1191
  if (!appId || grant.app.id === appId)
972
1192
  remove(grant);
1193
+ if (!toolJobs)
1194
+ return Promise.resolve();
1195
+ const appIds = appId
1196
+ ? [appId]
1197
+ : [...new Set([...jobOwners.values()].map((record) => record.scope.appId))];
1198
+ return Promise.all(appIds.map((id) => toolJobs.cancelApp(id))).then(() => undefined);
973
1199
  },
974
- close() {
1200
+ async close() {
975
1201
  closed = true;
976
1202
  clearInterval(reaper);
977
1203
  for (const grant of grants.values())
978
1204
  remove(grant);
979
- processes.close();
980
1205
  assets.clear();
981
1206
  for (const preparation of preparing)
982
1207
  preparation.cancelled = true;
983
- return Promise.all([resources.shutdown(), Promise.resolve(agentTasks?.close())]).then(() => undefined);
1208
+ try {
1209
+ await toolJobs?.shutdown();
1210
+ }
1211
+ finally {
1212
+ processes.close();
1213
+ await Promise.all([resources.shutdown(), Promise.resolve(agentTasks?.close())]);
1214
+ }
984
1215
  },
985
1216
  async handle(request, response) {
986
1217
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -1075,10 +1306,14 @@ export function createPanelRuntime(options) {
1075
1306
  json(response, 200, { accepted: true });
1076
1307
  }
1077
1308
  else if (request.method === "POST" && match[2] === "call") {
1078
- const value = await call(grant, await body(request));
1309
+ const input = await body(request);
1310
+ const value = await call(grant, input);
1079
1311
  if (!(await authorized(grant)))
1080
1312
  error(410, "面板授权已失效,请重新打开。");
1081
- if (Buffer.byteLength(JSON.stringify(value) ?? "null") > 3 * 1024 * 1024)
1313
+ const resultLimit = typeof input.method === "string" && input.method.startsWith("tasks.")
1314
+ ? toolJobLimits.maxRecordBytes + 128 * 1024
1315
+ : 3 * 1024 * 1024;
1316
+ if (Buffer.byteLength(JSON.stringify(value) ?? "null") > resultLimit)
1082
1317
  throw new PanelBridgeError("RESULT_TOO_LARGE", "Panel result exceeds the response limit");
1083
1318
  json(response, 200, value);
1084
1319
  }
@@ -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;
@@ -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
  }
@@ -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.20",
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.20",
55
+ "@cjhyy/code-shell-core": "0.9.20",
56
+ "@cjhyy/code-shell-web": "0.9.20",
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.20"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=20.10"