@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.
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/panels/bridge-contract.js +1 -0
- package/dist/panels/directory-bookmarks.d.ts +7 -0
- package/dist/panels/directory-bookmarks.js +141 -0
- package/dist/panels/http.d.ts +2 -2
- package/dist/panels/http.js +3 -3
- package/dist/panels/runtime.d.ts +2 -2
- package/dist/panels/runtime.js +266 -31
- package/dist/panels/tool-jobs.d.ts +1 -0
- package/dist/panels/tool-jobs.js +4 -0
- package/dist/serve/headless-server.js +2 -2
- package/package.json +5 -5
package/dist/index.panels.d.ts
CHANGED
package/dist/index.panels.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/panels/http.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/panels/http.js
CHANGED
|
@@ -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
|
};
|
package/dist/panels/runtime.d.ts
CHANGED
|
@@ -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<
|
|
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
|
};
|
package/dist/panels/runtime.js
CHANGED
|
@@ -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:
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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 === "
|
|
870
|
-
?
|
|
871
|
-
: name === "
|
|
872
|
-
? join(options.
|
|
873
|
-
: name === "
|
|
874
|
-
? join(options.dataDir, "panel-
|
|
875
|
-
:
|
|
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
|
-
|
|
1094
|
+
if (name !== "project")
|
|
1095
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
882
1096
|
if (!(await authorized(grant)))
|
|
883
1097
|
error(410, "面板授权已失效。");
|
|
884
|
-
|
|
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
|
-
|
|
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
|
|
1309
|
+
const input = await body(request);
|
|
1310
|
+
const value = await call(grant, input);
|
|
1079
1311
|
if (!(await authorized(grant)))
|
|
1080
1312
|
error(410, "面板授权已失效,请重新打开。");
|
|
1081
|
-
|
|
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
|
}
|
package/dist/panels/tool-jobs.js
CHANGED
|
@@ -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.
|
|
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.
|
|
55
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
56
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
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.
|
|
60
|
+
"@cjhyy/code-shell-link": "0.9.20"
|
|
61
61
|
},
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=20.10"
|