@cjhyy/code-shell-server 0.9.21 → 0.9.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -1
- package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
- package/dist/bin/code-shell-settings-recovery.js +4 -0
- package/dist/desktop-web/http-api.js +14 -0
- package/dist/environment-identity.d.ts +4 -0
- package/dist/environment-identity.js +67 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +7 -0
- package/dist/index.auth.d.ts +3 -0
- package/dist/index.auth.js +2 -0
- package/dist/index.links.d.ts +1 -0
- package/dist/index.links.js +1 -0
- package/dist/index.panels.d.ts +7 -0
- package/dist/index.panels.js +7 -0
- package/dist/index.remote-relay.d.ts +5 -0
- package/dist/index.remote-relay.js +6 -0
- package/dist/index.serve.d.ts +2 -0
- package/dist/index.serve.js +2 -0
- package/dist/links/http.d.ts +3 -0
- package/dist/links/http.js +11 -2
- package/dist/links/remote-configuration.d.ts +4 -0
- package/dist/links/remote-configuration.js +32 -0
- package/dist/links/service.d.ts +6 -1
- package/dist/links/service.js +241 -19
- package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
- package/dist/mobile-remote/mobile-upload-service.js +69 -11
- package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
- package/dist/mobile-remote/remote-host-manager.js +53 -5
- package/dist/panels/agent-task-service.js +9 -2
- package/dist/panels/automations.d.ts +49 -0
- package/dist/panels/automations.js +134 -0
- package/dist/panels/bridge-contract.d.ts +5 -3
- package/dist/panels/bridge-contract.js +11 -1
- package/dist/panels/directory-bookmarks.d.ts +9 -1
- package/dist/panels/directory-bookmarks.js +64 -5
- package/dist/panels/execution-gate.d.ts +26 -0
- package/dist/panels/execution-gate.js +70 -0
- package/dist/panels/http.d.ts +26 -2
- package/dist/panels/http.js +7 -1
- package/dist/panels/hub-automations.d.ts +31 -0
- package/dist/panels/hub-automations.js +352 -0
- package/dist/panels/management-http.d.ts +20 -2
- package/dist/panels/management-http.js +15 -2
- package/dist/panels/management.d.ts +26 -4
- package/dist/panels/management.js +382 -39
- package/dist/panels/package-mutation.d.ts +8 -0
- package/dist/panels/package-mutation.js +23 -0
- package/dist/panels/process-service.d.ts +3 -0
- package/dist/panels/process-service.js +77 -4
- package/dist/panels/resources/http.d.ts +11 -0
- package/dist/panels/resources/http.js +74 -0
- package/dist/panels/resources/references.d.ts +5 -0
- package/dist/panels/resources/references.js +26 -0
- package/dist/panels/runtime-services.js +16 -3
- package/dist/panels/runtime.d.ts +16 -4
- package/dist/panels/runtime.js +522 -64
- package/dist/panels/shared-tool-jobs.d.ts +40 -0
- package/dist/panels/shared-tool-jobs.js +37 -0
- package/dist/panels/storage-store.d.ts +21 -0
- package/dist/panels/storage-store.js +43 -0
- package/dist/panels/task-cookie-host.d.ts +26 -0
- package/dist/panels/task-cookie-host.js +145 -0
- package/dist/panels/task-cookies.d.ts +51 -0
- package/dist/panels/task-cookies.js +183 -0
- package/dist/panels/tool-executor.d.ts +21 -5
- package/dist/panels/tool-executor.js +171 -30
- package/dist/panels/tool-jobs-storage.d.ts +2 -0
- package/dist/panels/tool-jobs-storage.js +34 -0
- package/dist/panels/tool-jobs.d.ts +49 -3
- package/dist/panels/tool-jobs.js +270 -15
- package/dist/panels/types.d.ts +43 -0
- package/dist/project-runtime/backup.d.ts +26 -0
- package/dist/project-runtime/backup.js +500 -0
- package/dist/project-runtime/control-server.d.ts +4 -0
- package/dist/project-runtime/control-server.js +19 -1
- package/dist/project-runtime/docker-provider.d.ts +4 -0
- package/dist/project-runtime/docker-provider.js +16 -1
- package/dist/project-runtime/managed-entry.js +4 -0
- package/dist/project-runtime/registry.d.ts +2 -0
- package/dist/project-runtime/registry.js +15 -0
- package/dist/project-runtime/seccomp.d.ts +8 -0
- package/dist/project-runtime/seccomp.js +72 -0
- package/dist/project-runtime/types.d.ts +2 -0
- package/dist/remote-relay/byte-stream.d.ts +9 -0
- package/dist/remote-relay/byte-stream.js +71 -0
- package/dist/remote-relay/connector.d.ts +27 -0
- package/dist/remote-relay/connector.js +197 -0
- package/dist/remote-relay/local-stream.d.ts +10 -0
- package/dist/remote-relay/local-stream.js +120 -0
- package/dist/remote-relay/protocol.d.ts +39 -0
- package/dist/remote-relay/protocol.js +64 -0
- package/dist/serve/automation-worker.d.ts +16 -0
- package/dist/serve/automation-worker.js +126 -0
- package/dist/serve/cli.d.ts +3 -0
- package/dist/serve/cli.js +17 -0
- package/dist/serve/headless-server.d.ts +3 -0
- package/dist/serve/headless-server.js +140 -10
- package/dist/serve/settings-recovery-cli.d.ts +29 -0
- package/dist/serve/settings-recovery-cli.js +128 -0
- package/dist/session-http-responses.d.ts +7 -0
- package/dist/session-http-responses.js +40 -0
- package/dist/worker-bridge-core.d.ts +9 -1
- package/dist/worker-bridge-core.js +45 -0
- package/package.json +15 -6
|
@@ -7,6 +7,7 @@ import { WebSocketServer } from "ws";
|
|
|
7
7
|
import { serveMobile } from "./mobile-static.js";
|
|
8
8
|
import { PairingTokenManager } from "./pairing.js";
|
|
9
9
|
import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
|
|
10
|
+
import { isRelayOrigin } from "../remote-relay/protocol.js";
|
|
10
11
|
/**
|
|
11
12
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
12
13
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -40,6 +41,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
40
41
|
started;
|
|
41
42
|
starting;
|
|
42
43
|
stopping;
|
|
44
|
+
relayLifetime;
|
|
43
45
|
pairing = new PairingTokenManager();
|
|
44
46
|
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
45
47
|
authed = new WeakMap();
|
|
@@ -111,8 +113,14 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
async startOnce(options) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
+
const relay = options.mode === "relay";
|
|
117
|
+
const publicAccess = relay || options.mode === "tunnel";
|
|
118
|
+
if (relay && !options.passcode?.isSet()) {
|
|
119
|
+
throw new Error("Relay mode requires an initialized access passcode");
|
|
120
|
+
}
|
|
121
|
+
if (relay)
|
|
122
|
+
this.publicBaseUrl = undefined;
|
|
123
|
+
this.passcode = publicAccess ? options.passcode : undefined;
|
|
116
124
|
const gate = this.passcode;
|
|
117
125
|
this.opts.webApi?.start();
|
|
118
126
|
const server = createServer((req, res) => {
|
|
@@ -166,7 +174,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
166
174
|
if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
|
|
167
175
|
serveMobile(req, res, {
|
|
168
176
|
rootDir: this.mobileRootDir,
|
|
169
|
-
devUrl:
|
|
177
|
+
devUrl: publicAccess ? undefined : this.mobileDevUrl,
|
|
170
178
|
});
|
|
171
179
|
return;
|
|
172
180
|
}
|
|
@@ -195,6 +203,13 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
195
203
|
socket.destroy();
|
|
196
204
|
return;
|
|
197
205
|
}
|
|
206
|
+
if (relay &&
|
|
207
|
+
(!this.publicBaseUrl ||
|
|
208
|
+
req.headers.origin !== this.publicBaseUrl ||
|
|
209
|
+
req.headers.host !== new URL(this.publicBaseUrl).host)) {
|
|
210
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
198
213
|
if (!gate.allows(req)) {
|
|
199
214
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
200
215
|
socket.destroy();
|
|
@@ -277,7 +292,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
277
292
|
// host: "lan" → resolve the Mac's real LAN IPv4 so a phone on the same
|
|
278
293
|
// Wi-Fi can reach us. We bind that concrete address (never 0.0.0.0). If no
|
|
279
294
|
// LAN interface is found, fall back to the requested host (e.g. localhost).
|
|
280
|
-
const bindHost =
|
|
295
|
+
const bindHost = publicAccess
|
|
281
296
|
? "127.0.0.1"
|
|
282
297
|
: options.host === "lan"
|
|
283
298
|
? (resolveLanHost() ?? "127.0.0.1")
|
|
@@ -314,11 +329,12 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
314
329
|
}
|
|
315
330
|
const addr = server.address();
|
|
316
331
|
const port = typeof addr === "object" && addr ? addr.port : options.port;
|
|
332
|
+
this.relayLifetime = relay ? new AbortController() : undefined;
|
|
317
333
|
this.started = {
|
|
318
334
|
host: bindHost,
|
|
319
335
|
port,
|
|
320
336
|
url: `http://${bindHost}:${port}`,
|
|
321
|
-
mode:
|
|
337
|
+
mode: options.mode ?? "lan",
|
|
322
338
|
};
|
|
323
339
|
return this.started;
|
|
324
340
|
}
|
|
@@ -328,11 +344,35 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
328
344
|
* address, not the loopback bind). Pass undefined to clear it.
|
|
329
345
|
*/
|
|
330
346
|
setPublicBaseUrl(url) {
|
|
347
|
+
if (this.started?.mode === "relay" && url !== undefined && !isRelayOrigin(url)) {
|
|
348
|
+
throw new Error("Relay mode requires a canonical HTTPS origin");
|
|
349
|
+
}
|
|
331
350
|
this.publicBaseUrl = url;
|
|
332
351
|
}
|
|
352
|
+
/** Fixed loopback authority, revoked synchronously when this Host stops. */
|
|
353
|
+
relayTarget() {
|
|
354
|
+
const started = this.started;
|
|
355
|
+
const lifetime = this.relayLifetime;
|
|
356
|
+
if (started?.mode !== "relay" || !lifetime || lifetime.signal.aborted) {
|
|
357
|
+
throw new Error("Remote host is not running in relay mode");
|
|
358
|
+
}
|
|
359
|
+
return Object.freeze({
|
|
360
|
+
port: started.port,
|
|
361
|
+
signal: lifetime.signal,
|
|
362
|
+
setPublicBaseUrl: (origin) => {
|
|
363
|
+
lifetime.signal.throwIfAborted();
|
|
364
|
+
if (this.started !== started)
|
|
365
|
+
throw new Error("Relay Host generation expired");
|
|
366
|
+
this.setPublicBaseUrl(origin);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
333
370
|
createPairingUrl() {
|
|
334
371
|
if (!this.started)
|
|
335
372
|
throw new Error("Remote host is not running");
|
|
373
|
+
if (this.started.mode === "relay" && !this.publicBaseUrl) {
|
|
374
|
+
throw new Error("Relay public origin is not configured");
|
|
375
|
+
}
|
|
336
376
|
const token = this.pairing.createToken();
|
|
337
377
|
const base = this.publicBaseUrl ?? this.started.url;
|
|
338
378
|
return {
|
|
@@ -443,6 +483,9 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
443
483
|
}
|
|
444
484
|
/** Call after revoking/removing a trusted device to release both transports immediately. */
|
|
445
485
|
revokeDevice(deviceId) {
|
|
486
|
+
void this.opts.uploads?.revokeDevice(deviceId).catch((error) => {
|
|
487
|
+
this.emit("host-error", error instanceof Error ? error.message : String(error));
|
|
488
|
+
});
|
|
446
489
|
this.opts.webApi?.revokeDevice(deviceId);
|
|
447
490
|
for (const client of this.wss?.clients ?? []) {
|
|
448
491
|
if (this.authed.get(client) !== deviceId)
|
|
@@ -452,6 +495,9 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
452
495
|
}
|
|
453
496
|
}
|
|
454
497
|
stop() {
|
|
498
|
+
// Listeners synchronously fence and destroy outbound sockets before the
|
|
499
|
+
// loopback listener can close or its port can be reused by another Host.
|
|
500
|
+
this.relayLifetime?.abort();
|
|
455
501
|
if (this.stopping)
|
|
456
502
|
return this.stopping;
|
|
457
503
|
const attempt = Promise.resolve()
|
|
@@ -466,6 +512,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
466
512
|
async stopOnce() {
|
|
467
513
|
if (this.starting)
|
|
468
514
|
await this.starting.catch(() => undefined);
|
|
515
|
+
this.relayLifetime?.abort();
|
|
516
|
+
this.relayLifetime = undefined;
|
|
469
517
|
const server = this.server;
|
|
470
518
|
// Forcibly drop live WS sockets first. wss.close() alone waits for clients
|
|
471
519
|
// to disconnect, so a phone left connected would hang stop() indefinitely.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { panelExecutionGate } from "./execution-gate.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
const MAX_TASKS = 200;
|
|
3
4
|
const MAX_PROMPT_CHARS = 20_000;
|
|
@@ -130,10 +131,11 @@ export class PanelAppAgentTaskService {
|
|
|
130
131
|
cancelRequested: false,
|
|
131
132
|
activity: [],
|
|
132
133
|
};
|
|
134
|
+
const release = panelExecutionGate.enter(owner);
|
|
133
135
|
this.tasks.set(task.id, task);
|
|
134
136
|
this.trimHistory();
|
|
135
137
|
this.emit(task);
|
|
136
|
-
void this.execute(task);
|
|
138
|
+
void this.execute(task).finally(release);
|
|
137
139
|
return publicTask(task);
|
|
138
140
|
}
|
|
139
141
|
list(owner) {
|
|
@@ -237,7 +239,12 @@ export class PanelAppAgentTaskService {
|
|
|
237
239
|
return task;
|
|
238
240
|
}
|
|
239
241
|
emit(task) {
|
|
240
|
-
|
|
242
|
+
try {
|
|
243
|
+
this.emitChanged(task.owner, publicTask(task));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* A disconnected viewer must not interrupt execution or cleanup. */
|
|
247
|
+
}
|
|
241
248
|
}
|
|
242
249
|
recordProgress(task, progress) {
|
|
243
250
|
if (TERMINAL_STATUSES.has(task.status))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CronJob } from "@cjhyy/code-shell-core/internal";
|
|
2
|
+
export declare const panelAutomationMethods: readonly ["automations.list", "automations.create", "automations.createUnique", "automations.update", "automations.updateIfRevision", "automations.deleteIfRevision", "automations.pause", "automations.resume", "automations.delete", "automations.runNow"];
|
|
3
|
+
/** Derived by the transport; sessionId is still untrusted until the Host verifies its workspace. */
|
|
4
|
+
export interface PanelAutomationScope {
|
|
5
|
+
appId: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
/** Host-selected installed package revision, never a field from Panel call JSON. */
|
|
9
|
+
revision?: string;
|
|
10
|
+
isAuthorized(): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
/** Borrowed Host service. A page/transport closing never stops the scheduler. */
|
|
13
|
+
export interface PanelAutomationHost {
|
|
14
|
+
/** True only if revision checks happen inside the scheduler storage transaction. */
|
|
15
|
+
conditionalMutations?: boolean;
|
|
16
|
+
call(scope: PanelAutomationScope, method: string, params?: unknown): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
export declare function panelAutomationCreationKey(appId: string, cwd: string, sessionId: string, key: string): string;
|
|
19
|
+
/** Definition identity excludes changing run statistics and execution receipts. */
|
|
20
|
+
export declare function panelAutomationRevision(job: Readonly<CronJob>): string;
|
|
21
|
+
export declare class PanelAutomationConflictError extends Error {
|
|
22
|
+
constructor();
|
|
23
|
+
}
|
|
24
|
+
export declare function assertPanelAutomationRevision(job: Readonly<CronJob>, expected?: string): void;
|
|
25
|
+
type Definition = {
|
|
26
|
+
name: string;
|
|
27
|
+
schedule: string;
|
|
28
|
+
prompt: string;
|
|
29
|
+
timezone?: string;
|
|
30
|
+
};
|
|
31
|
+
export type PanelAutomationOperation = {
|
|
32
|
+
action: "list";
|
|
33
|
+
} | {
|
|
34
|
+
action: "create";
|
|
35
|
+
input: Definition;
|
|
36
|
+
key?: string;
|
|
37
|
+
} | {
|
|
38
|
+
action: "update";
|
|
39
|
+
id: string;
|
|
40
|
+
patch: Partial<Definition>;
|
|
41
|
+
expectedRevision?: string;
|
|
42
|
+
} | {
|
|
43
|
+
action: "pause" | "resume" | "delete" | "runNow";
|
|
44
|
+
id: string;
|
|
45
|
+
expectedRevision?: string;
|
|
46
|
+
};
|
|
47
|
+
/** Common wire validation; authority fields are never supplied by Panel JSON. */
|
|
48
|
+
export declare function parsePanelAutomationCall(method: string, params: unknown): PanelAutomationOperation;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const panelAutomationMethods = [
|
|
3
|
+
"automations.list",
|
|
4
|
+
"automations.create",
|
|
5
|
+
"automations.createUnique",
|
|
6
|
+
"automations.update",
|
|
7
|
+
"automations.updateIfRevision",
|
|
8
|
+
"automations.deleteIfRevision",
|
|
9
|
+
"automations.pause",
|
|
10
|
+
"automations.resume",
|
|
11
|
+
"automations.delete",
|
|
12
|
+
"automations.runNow",
|
|
13
|
+
];
|
|
14
|
+
export function panelAutomationCreationKey(appId, cwd, sessionId, key) {
|
|
15
|
+
return `panel:${createHash("sha256")
|
|
16
|
+
.update(JSON.stringify([appId, cwd, sessionId, key]))
|
|
17
|
+
.digest("hex")}`;
|
|
18
|
+
}
|
|
19
|
+
/** Definition identity excludes changing run statistics and execution receipts. */
|
|
20
|
+
export function panelAutomationRevision(job) {
|
|
21
|
+
return createHash("sha256")
|
|
22
|
+
.update(JSON.stringify([
|
|
23
|
+
job.id,
|
|
24
|
+
job.createdAt,
|
|
25
|
+
job.name,
|
|
26
|
+
job.schedule,
|
|
27
|
+
job.prompt,
|
|
28
|
+
job.enabled,
|
|
29
|
+
job.cwd ?? null,
|
|
30
|
+
job.projectId ?? null,
|
|
31
|
+
job.rootId ?? null,
|
|
32
|
+
job.resumeSessionId ?? null,
|
|
33
|
+
job.timezone ?? "UTC",
|
|
34
|
+
job.permissionLevel ?? "read-only",
|
|
35
|
+
job.once === true,
|
|
36
|
+
job.disabledReason ?? null,
|
|
37
|
+
job.creationKey ?? null,
|
|
38
|
+
job.panelSource?.appId ?? null,
|
|
39
|
+
job.panelSource?.revision ?? null,
|
|
40
|
+
job.templateSource?.installKey ?? null,
|
|
41
|
+
job.templateSource?.templateId ?? null,
|
|
42
|
+
job.templateSource?.revision ?? null,
|
|
43
|
+
job.templateSource?.pluginVersion ?? null,
|
|
44
|
+
]))
|
|
45
|
+
.digest("hex");
|
|
46
|
+
}
|
|
47
|
+
export class PanelAutomationConflictError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("Automation changed; reload before editing or deleting it");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function assertPanelAutomationRevision(job, expected) {
|
|
53
|
+
if (expected !== undefined && panelAutomationRevision(job) !== expected)
|
|
54
|
+
throw new PanelAutomationConflictError();
|
|
55
|
+
}
|
|
56
|
+
/** Common wire validation; authority fields are never supplied by Panel JSON. */
|
|
57
|
+
export function parsePanelAutomationCall(method, params) {
|
|
58
|
+
if (!panelAutomationMethods.includes(method))
|
|
59
|
+
throw Error("Unknown Panel automation method");
|
|
60
|
+
if (params !== undefined && (!params || typeof params !== "object" || Array.isArray(params)))
|
|
61
|
+
throw Error("Panel automation parameters must be an object");
|
|
62
|
+
const input = (params ?? {});
|
|
63
|
+
const conditional = method.endsWith("IfRevision");
|
|
64
|
+
const action = method.slice("automations.".length).replace(/IfRevision$/, "");
|
|
65
|
+
let expectedRevision;
|
|
66
|
+
if (conditional) {
|
|
67
|
+
if (typeof input.expectedRevision !== "string" ||
|
|
68
|
+
!/^[a-f0-9]{64}$/.test(input.expectedRevision))
|
|
69
|
+
throw Error("Panel automation expectedRevision is invalid");
|
|
70
|
+
expectedRevision = input.expectedRevision;
|
|
71
|
+
}
|
|
72
|
+
const create = action === "create" || action === "createUnique";
|
|
73
|
+
const allowed = action === "list"
|
|
74
|
+
? []
|
|
75
|
+
: create
|
|
76
|
+
? [
|
|
77
|
+
"name",
|
|
78
|
+
"schedule",
|
|
79
|
+
"prompt",
|
|
80
|
+
"timezone",
|
|
81
|
+
"permissionLevel",
|
|
82
|
+
...(action === "createUnique" ? ["key"] : []),
|
|
83
|
+
]
|
|
84
|
+
: action === "update"
|
|
85
|
+
? ["id", "name", "schedule", "prompt", "timezone"]
|
|
86
|
+
: ["id"];
|
|
87
|
+
if (conditional)
|
|
88
|
+
allowed.push("expectedRevision");
|
|
89
|
+
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
|
90
|
+
throw Error("Panel automation parameters contain unsupported or authority fields");
|
|
91
|
+
if (action === "list")
|
|
92
|
+
return { action };
|
|
93
|
+
let id = "";
|
|
94
|
+
if (!create) {
|
|
95
|
+
if (typeof input.id !== "string" || !input.id.trim() || input.id.length > 128)
|
|
96
|
+
throw Error("Panel automation id is invalid");
|
|
97
|
+
id = input.id.trim();
|
|
98
|
+
}
|
|
99
|
+
if (!create && action !== "update")
|
|
100
|
+
return {
|
|
101
|
+
action: action,
|
|
102
|
+
id,
|
|
103
|
+
...(expectedRevision ? { expectedRevision } : {}),
|
|
104
|
+
};
|
|
105
|
+
const patch = {};
|
|
106
|
+
for (const [key, limit] of [
|
|
107
|
+
["name", 120],
|
|
108
|
+
["schedule", 128],
|
|
109
|
+
["prompt", 20000],
|
|
110
|
+
["timezone", 120],
|
|
111
|
+
]) {
|
|
112
|
+
const value = input[key];
|
|
113
|
+
if (value === undefined && (!create || key === "timezone"))
|
|
114
|
+
continue;
|
|
115
|
+
if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
|
|
116
|
+
throw Error(`Panel automation ${key} is invalid`);
|
|
117
|
+
patch[key] = value.trim();
|
|
118
|
+
}
|
|
119
|
+
if (action === "update") {
|
|
120
|
+
if (!Object.keys(patch).length)
|
|
121
|
+
throw Error("Panel automation update is empty");
|
|
122
|
+
return { action, id, patch, ...(expectedRevision ? { expectedRevision } : {}) };
|
|
123
|
+
}
|
|
124
|
+
if (input.permissionLevel !== undefined && input.permissionLevel !== "full")
|
|
125
|
+
throw Error("Panel automation permissionLevel must be full");
|
|
126
|
+
if (action === "createUnique" &&
|
|
127
|
+
(typeof input.key !== "string" || !/^[A-Za-z0-9._:-]{1,80}$/.test(input.key)))
|
|
128
|
+
throw Error("Panel unique automation requires a bounded key");
|
|
129
|
+
return {
|
|
130
|
+
action: "create",
|
|
131
|
+
input: patch,
|
|
132
|
+
...(action === "createUnique" ? { key: input.key } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -27,6 +27,7 @@ export declare function panelBridgeFailure(error: unknown): {
|
|
|
27
27
|
};
|
|
28
28
|
export declare function panelRuntimeCapabilities(input: {
|
|
29
29
|
process: boolean;
|
|
30
|
+
cookieProcess?: boolean;
|
|
30
31
|
resources?: unknown;
|
|
31
32
|
tasks?: unknown;
|
|
32
33
|
limits?: Partial<Record<keyof typeof panelBridgeLimits, number>>;
|
|
@@ -37,7 +38,8 @@ export declare function panelRuntimeCapabilities(input: {
|
|
|
37
38
|
materialize: boolean;
|
|
38
39
|
capture: boolean;
|
|
39
40
|
} | undefined;
|
|
40
|
-
process?:
|
|
41
|
+
process?: {
|
|
42
|
+
cookieCredentials?: boolean | undefined;
|
|
41
43
|
maxArguments: 256;
|
|
42
44
|
maxArgumentLength: 8192;
|
|
43
45
|
maxArgumentBytes: number;
|
|
@@ -58,11 +60,11 @@ export declare function panelRuntimeCapabilities(input: {
|
|
|
58
60
|
stdinWriteTimeoutMs: 10000;
|
|
59
61
|
maxEntryBytes: number;
|
|
60
62
|
maxEntryHandlesPerGuest: 64;
|
|
61
|
-
}
|
|
63
|
+
} | undefined;
|
|
62
64
|
bridge: {
|
|
63
65
|
structuredErrors: boolean;
|
|
64
|
-
maxParamsBytes: number;
|
|
65
66
|
maxResultBytes: number;
|
|
67
|
+
maxParamsBytes: number;
|
|
66
68
|
rateWindowMs: number;
|
|
67
69
|
maxCallsPerWindow: number;
|
|
68
70
|
maxTransferCallsPerWindow: number;
|
|
@@ -34,8 +34,11 @@ export const panelToolJobMethods = [
|
|
|
34
34
|
"tasks.start",
|
|
35
35
|
"tasks.list",
|
|
36
36
|
"tasks.get",
|
|
37
|
+
"tasks.find",
|
|
37
38
|
"tasks.cancel",
|
|
38
39
|
"tasks.retry",
|
|
40
|
+
"tasks.queue.get",
|
|
41
|
+
"tasks.queue.set",
|
|
39
42
|
];
|
|
40
43
|
export const panelBridgeLimits = Object.freeze({
|
|
41
44
|
maxParamsBytes: 64 * 1024,
|
|
@@ -82,7 +85,14 @@ export function panelRuntimeCapabilities(input) {
|
|
|
82
85
|
"INVALID_ARGUMENT",
|
|
83
86
|
"OPERATION_FAILED",
|
|
84
87
|
],
|
|
85
|
-
...(input.process
|
|
88
|
+
...(input.process
|
|
89
|
+
? {
|
|
90
|
+
process: {
|
|
91
|
+
...processLimits,
|
|
92
|
+
...(input.cookieProcess ? { cookieCredentials: true } : {}),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
86
96
|
...(input.resources
|
|
87
97
|
? {
|
|
88
98
|
resources: {
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import type { InstalledPanelApp } from "@cjhyy/code-shell-core";
|
|
2
|
+
export type PanelDirectoryAuthorizer = (app: InstalledPanelApp, projectPath: string, workspacePath: string) => Promise<void>;
|
|
3
|
+
export declare function desktopPanelDirectoryBookmarks(dataDir: string): PanelAppDirectoryBookmarks;
|
|
4
|
+
/** The file mutex uses a sibling of its parent; keep both inside the data volume. */
|
|
5
|
+
export declare function hubPanelDirectoryBookmarks(dataDir: string): PanelAppDirectoryBookmarks;
|
|
1
6
|
/** The guest receives only a random bookmark. A path saved in guest storage grants nothing. */
|
|
2
7
|
export declare class PanelAppDirectoryBookmarks {
|
|
3
8
|
private readonly file;
|
|
4
|
-
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(file: string, options?: {
|
|
11
|
+
legacyFiles?: readonly string[];
|
|
12
|
+
});
|
|
5
13
|
remember(appId: string, projectPath: string, path: string): string;
|
|
6
14
|
restore(appId: string, projectPath: string, id: unknown): string;
|
|
7
15
|
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { acquireFileLock } from "@cjhyy/code-shell-core/internal";
|
|
5
|
+
export function desktopPanelDirectoryBookmarks(dataDir) {
|
|
6
|
+
return new PanelAppDirectoryBookmarks(join(dataDir, "panel-app-directory-bookmarks.json"), {
|
|
7
|
+
legacyFiles: [join(dataDir, "panel-web-directory-bookmarks.json")],
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** The file mutex uses a sibling of its parent; keep both inside the data volume. */
|
|
11
|
+
export function hubPanelDirectoryBookmarks(dataDir) {
|
|
12
|
+
return new PanelAppDirectoryBookmarks(join(dataDir, "panel-directories", "bookmarks.json"), {
|
|
13
|
+
legacyFiles: [join(dataDir, "panel-web-directory-bookmarks.json")],
|
|
14
|
+
});
|
|
15
|
+
}
|
|
5
16
|
const MAX_RECORDS = 256;
|
|
6
17
|
const MAX_BYTES = 256 * 1024;
|
|
7
18
|
const ID = /^[a-f0-9-]{36}$/i;
|
|
@@ -86,8 +97,10 @@ function write(file, bookmarks) {
|
|
|
86
97
|
/** The guest receives only a random bookmark. A path saved in guest storage grants nothing. */
|
|
87
98
|
export class PanelAppDirectoryBookmarks {
|
|
88
99
|
file;
|
|
89
|
-
|
|
100
|
+
options;
|
|
101
|
+
constructor(file, options = {}) {
|
|
90
102
|
this.file = file;
|
|
103
|
+
this.options = options;
|
|
91
104
|
}
|
|
92
105
|
remember(appId, projectPath, path) {
|
|
93
106
|
if (!/^[a-z][a-z0-9-]{0,63}$/.test(appId) || !isAbsolute(projectPath) || !isAbsolute(path))
|
|
@@ -108,9 +121,20 @@ export class PanelAppDirectoryBookmarks {
|
|
|
108
121
|
const release = acquireFileLock(this.file);
|
|
109
122
|
try {
|
|
110
123
|
const previous = read(this.file);
|
|
124
|
+
const existing = previous.find((item) => item.appId === appId &&
|
|
125
|
+
item.projectPath === projectPath &&
|
|
126
|
+
item.path === path &&
|
|
127
|
+
item.dev === info.dev &&
|
|
128
|
+
item.ino === info.ino);
|
|
129
|
+
if (existing)
|
|
130
|
+
bookmark.id = existing.id;
|
|
111
131
|
write(this.file, [
|
|
112
132
|
bookmark,
|
|
113
|
-
...previous.filter((item) =>
|
|
133
|
+
...previous.filter((item) => item.id !== bookmark.id &&
|
|
134
|
+
!(item.appId === appId &&
|
|
135
|
+
item.projectPath === projectPath &&
|
|
136
|
+
item.path === path &&
|
|
137
|
+
(item.dev !== info.dev || item.ino !== info.ino))),
|
|
114
138
|
].slice(0, MAX_RECORDS));
|
|
115
139
|
}
|
|
116
140
|
finally {
|
|
@@ -121,8 +145,18 @@ export class PanelAppDirectoryBookmarks {
|
|
|
121
145
|
restore(appId, projectPath, id) {
|
|
122
146
|
if (typeof id !== "string" || !ID.test(id))
|
|
123
147
|
throw new Error("Invalid directory bookmark");
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
let bookmark = read(this.file).find((item) => item.id === id);
|
|
149
|
+
let legacy = false;
|
|
150
|
+
if (!bookmark) {
|
|
151
|
+
for (const file of this.options.legacyFiles ?? []) {
|
|
152
|
+
bookmark = read(file).find((item) => item.id === id);
|
|
153
|
+
if (bookmark) {
|
|
154
|
+
legacy = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!bookmark || bookmark.appId !== appId || bookmark.projectPath !== projectPath)
|
|
126
160
|
throw new Error("Saved directory is unavailable; choose it again");
|
|
127
161
|
try {
|
|
128
162
|
const info = lstatSync(bookmark.path);
|
|
@@ -136,6 +170,31 @@ export class PanelAppDirectoryBookmarks {
|
|
|
136
170
|
catch {
|
|
137
171
|
throw new Error("Saved directory changed; choose it again");
|
|
138
172
|
}
|
|
173
|
+
if (legacy) {
|
|
174
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
175
|
+
const release = acquireFileLock(this.file);
|
|
176
|
+
try {
|
|
177
|
+
const previous = read(this.file);
|
|
178
|
+
const existing = previous.find((item) => item.id === id);
|
|
179
|
+
if (existing &&
|
|
180
|
+
(existing.appId !== bookmark.appId ||
|
|
181
|
+
existing.projectPath !== bookmark.projectPath ||
|
|
182
|
+
existing.path !== bookmark.path ||
|
|
183
|
+
existing.dev !== bookmark.dev ||
|
|
184
|
+
existing.ino !== bookmark.ino))
|
|
185
|
+
throw new Error("Saved directory identity conflicts with the current grant");
|
|
186
|
+
if (!existing) {
|
|
187
|
+
// Migration never silently evicts another live bookmark. Preserve legacy
|
|
188
|
+
// IDs as aliases when both interfaces previously selected the same path.
|
|
189
|
+
if (previous.length >= MAX_RECORDS)
|
|
190
|
+
throw new Error("Panel directory bookmarks are full");
|
|
191
|
+
write(this.file, [bookmark, ...previous]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
release();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
139
198
|
return bookmark.path;
|
|
140
199
|
}
|
|
141
200
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PanelExecutionScope {
|
|
2
|
+
appId: string;
|
|
3
|
+
/** Host-verified binding project, never a directory supplied by the guest. */
|
|
4
|
+
projectPath: string;
|
|
5
|
+
}
|
|
6
|
+
/** Existing path aliases must not create separate admission domains. */
|
|
7
|
+
export declare function panelExecutionProject(path: string): string;
|
|
8
|
+
export declare class PanelExecutionBusyError extends Error {
|
|
9
|
+
readonly status = 409;
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
/** One process owns admission for every Panel runtime sharing its installed catalog.
|
|
13
|
+
* Acquire synchronously before authorization/IO; release after real execution cleanup,
|
|
14
|
+
* not after a UI timeout, cancellation request or a terminal status notification.
|
|
15
|
+
*/
|
|
16
|
+
export declare class PanelExecutionGate {
|
|
17
|
+
private readonly executions;
|
|
18
|
+
private readonly sources;
|
|
19
|
+
private readonly mutations;
|
|
20
|
+
enter(scope: PanelExecutionScope): () => void;
|
|
21
|
+
run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
|
|
22
|
+
/** Durable queues remain occupied even while paused and between process attempts. */
|
|
23
|
+
register(source: () => Iterable<PanelExecutionScope>): () => void;
|
|
24
|
+
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
export declare const panelExecutionGate: PanelExecutionGate;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/** Existing path aliases must not create separate admission domains. */
|
|
4
|
+
export function panelExecutionProject(path) {
|
|
5
|
+
try {
|
|
6
|
+
return realpathSync(path);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return resolve(path);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class PanelExecutionBusyError extends Error {
|
|
13
|
+
status = 409;
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "PanelExecutionBusyError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** One process owns admission for every Panel runtime sharing its installed catalog.
|
|
20
|
+
* Acquire synchronously before authorization/IO; release after real execution cleanup,
|
|
21
|
+
* not after a UI timeout, cancellation request or a terminal status notification.
|
|
22
|
+
*/
|
|
23
|
+
export class PanelExecutionGate {
|
|
24
|
+
executions = new Set();
|
|
25
|
+
sources = new Set();
|
|
26
|
+
mutations = new Set();
|
|
27
|
+
enter(scope) {
|
|
28
|
+
const held = { appId: scope.appId, projectPath: panelExecutionProject(scope.projectPath) };
|
|
29
|
+
if ([...this.mutations].some((matches) => matches(held)))
|
|
30
|
+
throw new PanelExecutionBusyError("面板正在更新,请完成后重试。");
|
|
31
|
+
this.executions.add(held);
|
|
32
|
+
return () => {
|
|
33
|
+
this.executions.delete(held);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async run(scope, work) {
|
|
37
|
+
const release = this.enter(scope);
|
|
38
|
+
try {
|
|
39
|
+
return await work();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
release();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Durable queues remain occupied even while paused and between process attempts. */
|
|
46
|
+
register(source) {
|
|
47
|
+
this.sources.add(source);
|
|
48
|
+
return () => {
|
|
49
|
+
this.sources.delete(source);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async mutate(matches, work) {
|
|
53
|
+
if (this.mutations.size)
|
|
54
|
+
throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
|
|
55
|
+
// No await between the snapshot and installing the admission barrier.
|
|
56
|
+
if ([...this.executions].some(matches) ||
|
|
57
|
+
[...this.sources].some((source) => [...source()].some(matches)))
|
|
58
|
+
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。");
|
|
59
|
+
this.mutations.add(matches);
|
|
60
|
+
try {
|
|
61
|
+
return await work();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
this.mutations.delete(matches);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Shared by native Desktop, its paired Web runtimes, and all Hub workspaces in this process.
|
|
69
|
+
// This is not a cross-process catalog lock; management also serializes catalog writes.
|
|
70
|
+
export const panelExecutionGate = new PanelExecutionGate();
|