@cjhyy/code-shell-server 0.9.22 → 0.9.25
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/clone.d.ts +13 -0
- package/dist/panels/resources/clone.js +99 -0
- 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/resources/service.js +63 -27
- 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/sessions-service.d.ts +6 -2
- package/dist/sessions-service.js +23 -4
- package/dist/worker-bridge-core.d.ts +5 -0
- package/dist/worker-bridge-core.js +23 -0
- package/package.json +15 -6
|
@@ -271,6 +271,7 @@ export class PanelAppProcessService {
|
|
|
271
271
|
await this.authorize(owner, epoch);
|
|
272
272
|
const info = await stat(resolved);
|
|
273
273
|
await this.authorize(owner, epoch);
|
|
274
|
+
await input.validate?.();
|
|
274
275
|
this.assertLive(owner, epoch);
|
|
275
276
|
if (!info.isFile())
|
|
276
277
|
throw new Error("sealed process input is not a file");
|
|
@@ -283,6 +284,7 @@ export class PanelAppProcessService {
|
|
|
283
284
|
argumentName: input.argumentName,
|
|
284
285
|
path: resolved,
|
|
285
286
|
cleanup: input.cleanup ?? (() => undefined),
|
|
287
|
+
validate: input.validate,
|
|
286
288
|
});
|
|
287
289
|
return { handle };
|
|
288
290
|
}
|
|
@@ -391,6 +393,31 @@ export class PanelAppProcessService {
|
|
|
391
393
|
}
|
|
392
394
|
const rawArgs = validateArguments(input.args);
|
|
393
395
|
const fileArgumentHandles = validateFileArgumentHandles(input.fileArgumentHandles);
|
|
396
|
+
const validateInputs = async () => {
|
|
397
|
+
for (const handle of fileArgumentHandles) {
|
|
398
|
+
const grant = this.fileArguments.get(handle);
|
|
399
|
+
if (!grant || !sameProcessOwner(grant, owner))
|
|
400
|
+
throw new Error("sealed process input was revoked");
|
|
401
|
+
try {
|
|
402
|
+
await grant.validate?.();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
if (this.fileArguments.get(handle) === grant) {
|
|
406
|
+
this.fileArguments.delete(handle);
|
|
407
|
+
try {
|
|
408
|
+
grant.cleanup();
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
/* Revocation still takes effect. */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
throw new Error("sealed process input authorization changed; authorize it again");
|
|
415
|
+
}
|
|
416
|
+
this.assertLive(owner, epoch);
|
|
417
|
+
if (this.fileArguments.get(handle) !== grant)
|
|
418
|
+
throw new Error("sealed process input was revoked");
|
|
419
|
+
}
|
|
420
|
+
};
|
|
394
421
|
const sealedArgs = [];
|
|
395
422
|
for (const handle of fileArgumentHandles) {
|
|
396
423
|
const grant = this.fileArguments.get(handle);
|
|
@@ -411,6 +438,8 @@ export class PanelAppProcessService {
|
|
|
411
438
|
throw new Error(`Panel App may run at most ${MAX_CONCURRENT_PROCESSES} processes at once`);
|
|
412
439
|
}
|
|
413
440
|
// Admission is reserved before the first await, including a user's approval dialog.
|
|
441
|
+
const releaseExecution = this.options.acquireExecution?.(owner);
|
|
442
|
+
let processOwnsExecution = false;
|
|
414
443
|
this.reservations.set(owner.guestId, reserved + 1);
|
|
415
444
|
try {
|
|
416
445
|
await this.authorize(owner, epoch);
|
|
@@ -463,6 +492,8 @@ export class PanelAppProcessService {
|
|
|
463
492
|
if ((await verifyProcessEntry(entry.path, entry.sha256)) !== entry.identity)
|
|
464
493
|
throw new Error("reviewed package entry was replaced; resolve it again");
|
|
465
494
|
}
|
|
495
|
+
await validateInputs();
|
|
496
|
+
await this.authorize(owner, epoch);
|
|
466
497
|
const env = safeProcessEnv(this.options.env ?? process.env, this.options.extraPathDirectories?.() ?? []);
|
|
467
498
|
this.assertLive(owner, epoch);
|
|
468
499
|
if (this.executables.get(input.executableHandle) !== executable ||
|
|
@@ -570,29 +601,71 @@ export class PanelAppProcessService {
|
|
|
570
601
|
}),
|
|
571
602
|
};
|
|
572
603
|
this.processes.set(processId, running);
|
|
604
|
+
let checkingInputs = false;
|
|
605
|
+
const inputChecks = fileArgumentHandles.some((handle) => this.fileArguments.get(handle)?.validate)
|
|
606
|
+
? setInterval(() => {
|
|
607
|
+
if (checkingInputs || this.processes.get(processId) !== running || running.termination)
|
|
608
|
+
return;
|
|
609
|
+
checkingInputs = true;
|
|
610
|
+
void validateInputs()
|
|
611
|
+
.catch(() => {
|
|
612
|
+
if (this.processes.get(processId) !== running || running.termination)
|
|
613
|
+
return;
|
|
614
|
+
emit("process.output", {
|
|
615
|
+
processId,
|
|
616
|
+
stream: "stderr",
|
|
617
|
+
text: "Process input authorization changed; CodeShell stopped it.\n",
|
|
618
|
+
});
|
|
619
|
+
this.terminate(running);
|
|
620
|
+
})
|
|
621
|
+
.finally(() => {
|
|
622
|
+
checkingInputs = false;
|
|
623
|
+
});
|
|
624
|
+
}, 1000)
|
|
625
|
+
: undefined;
|
|
626
|
+
inputChecks?.unref();
|
|
573
627
|
child.once("close", (code, signal) => {
|
|
574
628
|
void (async () => {
|
|
575
629
|
clearTimeout(lifetime);
|
|
630
|
+
if (inputChecks)
|
|
631
|
+
clearInterval(inputChecks);
|
|
576
632
|
// Cancellation also waits for the process group, not just its leader.
|
|
577
633
|
await running.termination;
|
|
578
|
-
|
|
634
|
+
let finalCode = code;
|
|
635
|
+
if (inputChecks && this.processes.get(processId) === running) {
|
|
636
|
+
try {
|
|
637
|
+
await validateInputs();
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
finalCode = code === 0 ? 1 : code;
|
|
641
|
+
emit("process.output", {
|
|
642
|
+
processId,
|
|
643
|
+
stream: "stderr",
|
|
644
|
+
text: "Process input authorization changed; results were rejected.\n",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
finishExit({ code: finalCode, signal });
|
|
579
649
|
this.retiring.delete(processId);
|
|
580
650
|
if (this.processes.get(processId)?.child !== child)
|
|
581
651
|
return;
|
|
582
652
|
this.processes.delete(processId);
|
|
583
653
|
record.status = "exited";
|
|
584
654
|
record.exitedAt = this.now();
|
|
585
|
-
record.code =
|
|
655
|
+
record.code = finalCode;
|
|
586
656
|
record.signal = signal;
|
|
587
|
-
emit("process.exit", { processId, code, signal });
|
|
657
|
+
emit("process.exit", { processId, code: finalCode, signal });
|
|
588
658
|
// Keep the receipt only after the operating system reports the real close.
|
|
589
659
|
if (!this.closed && (this.guestEpochs.get(owner.guestId) ?? 0) === epoch)
|
|
590
660
|
this.receipts.add(record);
|
|
591
|
-
})();
|
|
661
|
+
})().finally(() => releaseExecution?.());
|
|
592
662
|
});
|
|
663
|
+
processOwnsExecution = true;
|
|
593
664
|
return { processId, executable: executable.name };
|
|
594
665
|
}
|
|
595
666
|
finally {
|
|
667
|
+
if (!processOwnsExecution)
|
|
668
|
+
releaseExecution?.();
|
|
596
669
|
const remaining = (this.reservations.get(owner.guestId) ?? 1) - 1;
|
|
597
670
|
if (remaining)
|
|
598
671
|
this.reservations.set(owner.guestId, remaining);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Host-only optimization. Both paths are managed storage, never guest paths. */
|
|
2
|
+
export declare function stageResourceClone(options: {
|
|
3
|
+
sourcePath: string;
|
|
4
|
+
stagingDirectory: string;
|
|
5
|
+
targetDevice: number;
|
|
6
|
+
verify(): Promise<void>;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
path: string;
|
|
10
|
+
handle: import("fs/promises").FileHandle;
|
|
11
|
+
verify: () => Promise<void>;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
} | undefined>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, lstat, mkdtemp, open, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { sameResourceIdentity } from "./directories.js";
|
|
6
|
+
import { mediaSourceIdentity } from "./library.js";
|
|
7
|
+
/** Host-only optimization. Both paths are managed storage, never guest paths. */
|
|
8
|
+
export async function stageResourceClone(options) {
|
|
9
|
+
const { sourcePath, stagingDirectory, signal } = options;
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
await options.verify();
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const source = await lstat(sourcePath);
|
|
14
|
+
if (!source.isFile() || source.isSymbolicLink())
|
|
15
|
+
throw new Error("Invalid resource clone source");
|
|
16
|
+
if (source.dev !== options.targetDevice || !["darwin", "linux"].includes(process.platform))
|
|
17
|
+
return undefined;
|
|
18
|
+
const staging = await lstat(stagingDirectory);
|
|
19
|
+
if (!staging.isDirectory() || staging.isSymbolicLink())
|
|
20
|
+
throw new Error("Invalid resource clone staging directory");
|
|
21
|
+
if (staging.dev !== source.dev)
|
|
22
|
+
return undefined;
|
|
23
|
+
const directory = await mkdtemp(join(stagingDirectory, "clone-"));
|
|
24
|
+
const identity = await lstat(directory);
|
|
25
|
+
const path = join(directory, "content");
|
|
26
|
+
let handle;
|
|
27
|
+
const verify = async () => {
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
await options.verify();
|
|
30
|
+
const currentStaging = await lstat(stagingDirectory);
|
|
31
|
+
const currentDirectory = await lstat(directory);
|
|
32
|
+
const currentSource = await lstat(sourcePath);
|
|
33
|
+
if (!currentStaging.isDirectory() ||
|
|
34
|
+
currentStaging.isSymbolicLink() ||
|
|
35
|
+
!sameResourceIdentity(currentStaging, staging) ||
|
|
36
|
+
!currentDirectory.isDirectory() ||
|
|
37
|
+
currentDirectory.isSymbolicLink() ||
|
|
38
|
+
!sameResourceIdentity(currentDirectory, identity) ||
|
|
39
|
+
!currentSource.isFile() ||
|
|
40
|
+
currentSource.isSymbolicLink() ||
|
|
41
|
+
Object.entries(mediaSourceIdentity(source)).some(([key, value]) => currentSource[key] !== value))
|
|
42
|
+
throw new Error("Resource changed during clone");
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
};
|
|
45
|
+
const close = async () => {
|
|
46
|
+
await handle?.close().catch(() => { });
|
|
47
|
+
handle = undefined;
|
|
48
|
+
const current = await lstat(directory).catch(() => undefined);
|
|
49
|
+
if (current?.isDirectory() && sameResourceIdentity(current, identity))
|
|
50
|
+
await rm(directory, { recursive: true, force: true }).catch(() => { });
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
await verify();
|
|
54
|
+
try {
|
|
55
|
+
if (process.platform === "darwin") {
|
|
56
|
+
// Electron's Node/libuv does not implement FICLONE on macOS. The OS
|
|
57
|
+
// utility uses clonefile, with ordinary copy fallback on other volumes.
|
|
58
|
+
// Its target is fresh Host-private storage, outside the tool grant.
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
const child = spawn("/bin/cp", ["-c", "-n", sourcePath, path], {
|
|
61
|
+
stdio: "ignore",
|
|
62
|
+
signal,
|
|
63
|
+
});
|
|
64
|
+
child.once("error", reject);
|
|
65
|
+
child.once("close", (code) => {
|
|
66
|
+
if (code === 0)
|
|
67
|
+
resolve();
|
|
68
|
+
else
|
|
69
|
+
reject(new Error("Native resource clone failed"));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// FORCE prevents a failed Linux reflink from silently doing another
|
|
75
|
+
// full copy before the existing streaming fallback is used.
|
|
76
|
+
await copyFile(sourcePath, path, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE_FORCE);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await verify();
|
|
81
|
+
if (process.platform !== "darwin" &&
|
|
82
|
+
!["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EINVAL"].includes(error.code ?? ""))
|
|
83
|
+
throw error;
|
|
84
|
+
await close();
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
await verify();
|
|
88
|
+
handle = await open(path, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
89
|
+
const cloned = await handle.stat();
|
|
90
|
+
if (!cloned.isFile() || cloned.size !== source.size || sameResourceIdentity(cloned, source))
|
|
91
|
+
throw new Error("Invalid cloned resource");
|
|
92
|
+
await handle.chmod(0o600);
|
|
93
|
+
return { path, handle, verify, close };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await close();
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { PanelResourceService } from "./service.js";
|
|
3
|
+
import type { ResourceScope } from "./types.js";
|
|
4
|
+
/** Browser byte transport; scope and reader are resolved by the authenticated Host. */
|
|
5
|
+
export declare function servePanelResource(request: IncomingMessage, response: ServerResponse, options: {
|
|
6
|
+
service: PanelResourceService;
|
|
7
|
+
scope: ResourceScope;
|
|
8
|
+
id: string;
|
|
9
|
+
download: boolean;
|
|
10
|
+
isAuthorized(): Promise<boolean>;
|
|
11
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
/** Browser byte transport; scope and reader are resolved by the authenticated Host. */
|
|
4
|
+
export async function servePanelResource(request, response, options) {
|
|
5
|
+
let source = null;
|
|
6
|
+
const authorize = async () => {
|
|
7
|
+
if (request.aborted || response.destroyed || !(await options.isAuthorized()))
|
|
8
|
+
throw new Error("Resource authorization expired");
|
|
9
|
+
};
|
|
10
|
+
try {
|
|
11
|
+
await authorize();
|
|
12
|
+
if (!["GET", "HEAD"].includes(request.method ?? "")) {
|
|
13
|
+
response.writeHead(405, { Allow: "GET, HEAD" }).end();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const asset = await options.service.get(options.scope, options.id);
|
|
17
|
+
const result = await options.service.openRead(options.scope, options.id, {
|
|
18
|
+
range: request.headers.range,
|
|
19
|
+
method: asset.bytes === 0 ? "HEAD" : request.method,
|
|
20
|
+
});
|
|
21
|
+
source = result.body;
|
|
22
|
+
await authorize();
|
|
23
|
+
const headers = { ...result.headers, "Referrer-Policy": "no-referrer" };
|
|
24
|
+
if (options.download)
|
|
25
|
+
headers["Content-Disposition"] =
|
|
26
|
+
`attachment; filename*=UTF-8''${encodeURIComponent(asset.name).replace(/['()*!]/g, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
27
|
+
response.writeHead(result.status, headers);
|
|
28
|
+
if (!source) {
|
|
29
|
+
response.end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let checking = false;
|
|
33
|
+
const timer = setInterval(() => {
|
|
34
|
+
if (checking)
|
|
35
|
+
return;
|
|
36
|
+
checking = true;
|
|
37
|
+
void authorize()
|
|
38
|
+
.catch(() => {
|
|
39
|
+
source?.destroy();
|
|
40
|
+
response.destroy();
|
|
41
|
+
})
|
|
42
|
+
.finally(() => {
|
|
43
|
+
checking = false;
|
|
44
|
+
});
|
|
45
|
+
}, 250);
|
|
46
|
+
timer.unref();
|
|
47
|
+
try {
|
|
48
|
+
await pipeline(Readable.from((async function* () {
|
|
49
|
+
for await (const chunk of source) {
|
|
50
|
+
await authorize();
|
|
51
|
+
yield chunk;
|
|
52
|
+
}
|
|
53
|
+
})(), { objectMode: false }), response);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
if (response.headersSent || response.destroyed)
|
|
61
|
+
response.destroy();
|
|
62
|
+
else
|
|
63
|
+
response
|
|
64
|
+
.writeHead(404, {
|
|
65
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
66
|
+
"Cache-Control": "no-store",
|
|
67
|
+
"X-Content-Type-Options": "nosniff",
|
|
68
|
+
})
|
|
69
|
+
.end(request.method === "HEAD" ? undefined : "文件不可用或访问授权已失效,请重新打开面板。");
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
source?.destroy();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -30,6 +30,11 @@ export declare class ExternalResourceReferences {
|
|
|
30
30
|
createFromDirectory(scope: ResourceScope, root: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
|
|
31
31
|
createFromSelectedPath(scope: ResourceScope, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
|
|
32
32
|
get(scope: ResourceScope, id: string): Promise<ExternalResourceReference>;
|
|
33
|
+
/**
|
|
34
|
+
* Host-only: the verified absolute location of a reference, for a read-only
|
|
35
|
+
* handoff to a reviewed tool. Never return it to a Guest.
|
|
36
|
+
*/
|
|
37
|
+
location(scope: ResourceScope, id: string, options?: ExternalReferenceOptions): Promise<string>;
|
|
33
38
|
relinkFromDirectory(scope: ResourceScope, id: string, root: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
|
|
34
39
|
relinkFromSelectedPath(scope: ResourceScope, id: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
|
|
35
40
|
forget(scope: ResourceScope, id: string): Promise<{
|
|
@@ -270,6 +270,32 @@ export class ExternalResourceReferences {
|
|
|
270
270
|
}
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Host-only: the verified absolute location of a reference, for a read-only
|
|
275
|
+
* handoff to a reviewed tool. Never return it to a Guest.
|
|
276
|
+
*/
|
|
277
|
+
location(scope, id, options = {}) {
|
|
278
|
+
return this.locked(scope, async () => {
|
|
279
|
+
const record = await this.record(scope, id);
|
|
280
|
+
const source = await this.checked(scope, record, options);
|
|
281
|
+
try {
|
|
282
|
+
await source.verify();
|
|
283
|
+
return join(record.root, record.path);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
// A drive may disappear after checked() returns. Preserve revocation
|
|
287
|
+
// and abort semantics before hiding native errors containing its path.
|
|
288
|
+
await this.authorize(scope, options);
|
|
289
|
+
if (error instanceof ReferenceUnavailable)
|
|
290
|
+
throw error;
|
|
291
|
+
const code = error.code;
|
|
292
|
+
throw new ReferenceUnavailable(["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code ?? "") ? "missing" : "changed");
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
await source.close();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
273
299
|
relinkFromDirectory(scope, id, root, path, options = {}) {
|
|
274
300
|
return this.locked(scope, async () => {
|
|
275
301
|
const previous = await this.record(scope, id);
|
|
@@ -2,7 +2,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { link, lstat, open, rm } from "node:fs/promises";
|
|
4
4
|
import { ResourceLibrary, mediaSourceIdentity } from "./library.js";
|
|
5
|
-
import { mediaScopeKey, normalizeMediaScope } from "./storage.js";
|
|
5
|
+
import { mediaDirectory, mediaScopeKey, normalizeMediaScope } from "./storage.js";
|
|
6
|
+
import { stageResourceClone } from "./clone.js";
|
|
6
7
|
import { openResourceDirectory, resourceRelativePath, sameResourceIdentity, } from "./directories.js";
|
|
7
8
|
import { ResourceUploadIngest } from "./uploads.js";
|
|
8
9
|
import { ExternalResourceReferences } from "./references.js";
|
|
@@ -138,43 +139,71 @@ export class PanelResourceService {
|
|
|
138
139
|
await directory.close();
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
|
-
async materialize(scope, raw, context) {
|
|
142
|
+
async materialize(scope, raw, context, cloneManaged = true) {
|
|
142
143
|
const input = object(raw, ["assetId", "directoryHandle", "path"]);
|
|
143
144
|
const asset = await this.get(scope, assetId(input.assetId));
|
|
144
145
|
const directory = await this.directory(scope, input, context, true);
|
|
145
|
-
|
|
146
|
+
let temporary = directory.location(`.${randomUUID()}.resource-partial`);
|
|
147
|
+
let clone;
|
|
146
148
|
let destination;
|
|
147
149
|
let published = false;
|
|
148
150
|
let identity;
|
|
149
151
|
try {
|
|
150
152
|
await directory.verify();
|
|
151
|
-
|
|
153
|
+
if (cloneManaged && "sha256" in asset) {
|
|
154
|
+
const sourcePath = await this.library.resolvePath(scope, asset.id);
|
|
155
|
+
const stagingDirectory = await mediaDirectory(this.options.rootDirectory, [
|
|
156
|
+
"scopes",
|
|
157
|
+
mediaScopeKey(scope),
|
|
158
|
+
"materialize",
|
|
159
|
+
]);
|
|
160
|
+
clone = await stageResourceClone({
|
|
161
|
+
sourcePath,
|
|
162
|
+
stagingDirectory,
|
|
163
|
+
targetDevice: directory.identities.at(-1).dev,
|
|
164
|
+
verify: directory.verify,
|
|
165
|
+
signal: context.signal,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (clone) {
|
|
169
|
+
temporary = clone.path;
|
|
170
|
+
destination = clone.handle;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
destination = await open(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
174
|
+
}
|
|
152
175
|
identity = await destination.stat();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
offset
|
|
176
|
+
let sha256;
|
|
177
|
+
if (clone && "sha256" in asset) {
|
|
178
|
+
sha256 = asset.sha256;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const source = await this.openRead(scope, asset.id, { signal: context.signal });
|
|
182
|
+
const hash = createHash("sha256");
|
|
183
|
+
let bytes = 0;
|
|
184
|
+
try {
|
|
185
|
+
for await (const chunk of source.body) {
|
|
186
|
+
await directory.verify();
|
|
187
|
+
if (bytes + chunk.length > asset.bytes)
|
|
188
|
+
throw new Error("Resource content changed");
|
|
189
|
+
hash.update(chunk);
|
|
190
|
+
let offset = 0;
|
|
191
|
+
while (offset < chunk.length) {
|
|
192
|
+
const written = await destination.write(chunk, offset, chunk.length - offset, bytes + offset);
|
|
193
|
+
if (!written.bytesWritten)
|
|
194
|
+
throw new Error("Resource copy stopped making progress");
|
|
195
|
+
offset += written.bytesWritten;
|
|
196
|
+
}
|
|
197
|
+
bytes += chunk.length;
|
|
168
198
|
}
|
|
169
|
-
bytes += chunk.length;
|
|
170
199
|
}
|
|
200
|
+
finally {
|
|
201
|
+
source.body?.destroy();
|
|
202
|
+
}
|
|
203
|
+
sha256 = hash.digest("hex");
|
|
204
|
+
if (bytes !== asset.bytes || ("sha256" in asset && sha256 !== asset.sha256))
|
|
205
|
+
throw new Error("Resource content failed integrity validation");
|
|
171
206
|
}
|
|
172
|
-
finally {
|
|
173
|
-
source.body?.destroy();
|
|
174
|
-
}
|
|
175
|
-
const sha256 = hash.digest("hex");
|
|
176
|
-
if (bytes !== asset.bytes || ("sha256" in asset && sha256 !== asset.sha256))
|
|
177
|
-
throw new Error("Resource content failed integrity validation");
|
|
178
207
|
await destination.sync();
|
|
179
208
|
// A tool may share the granted directory. Verify the actual destination,
|
|
180
209
|
// not only the source stream, before publishing the materialized file.
|
|
@@ -195,6 +224,7 @@ export class PanelResourceService {
|
|
|
195
224
|
verified.ctimeMs !== completed.ctimeMs ||
|
|
196
225
|
copiedHash.digest("hex") !== sha256)
|
|
197
226
|
throw new Error("Materialized resource failed integrity verification");
|
|
227
|
+
await clone?.verify();
|
|
198
228
|
await destination.close();
|
|
199
229
|
destination = undefined;
|
|
200
230
|
await directory.verify();
|
|
@@ -213,15 +243,21 @@ export class PanelResourceService {
|
|
|
213
243
|
if (current && sameResourceIdentity(current, identity))
|
|
214
244
|
await rm(directory.path, { force: true }).catch(() => { });
|
|
215
245
|
}
|
|
216
|
-
|
|
246
|
+
// Different bind mounts can share st_dev yet refuse hard links. Retry
|
|
247
|
+
// with a temporary file inside the target grant, after releasing this
|
|
248
|
+
// private clone and all held descriptors in finally.
|
|
249
|
+
if (!(clone && !published && error.code === "EXDEV"))
|
|
250
|
+
throw error;
|
|
217
251
|
}
|
|
218
252
|
finally {
|
|
219
253
|
await destination?.close().catch(() => { });
|
|
220
254
|
const current = await lstat(temporary).catch(() => undefined);
|
|
221
255
|
if (current && identity && sameResourceIdentity(current, identity))
|
|
222
256
|
await rm(temporary, { force: true }).catch(() => { });
|
|
257
|
+
await clone?.close();
|
|
223
258
|
await directory.close();
|
|
224
259
|
}
|
|
260
|
+
return this.materialize(scope, raw, context, false);
|
|
225
261
|
}
|
|
226
262
|
async capture(scope, raw, context) {
|
|
227
263
|
const input = object(raw, [
|
|
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import { acquireLockOnPath } from "@cjhyy/code-shell-core/internal";
|
|
6
|
-
import { DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES, panelAppStorageKey, panelAppStoragePath, preparePanelAppStorage, readPanelAppStorage, writePanelAppStorage, } from "./storage-store.js";
|
|
6
|
+
import { DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES, panelAppStorageSnapshot, panelAppStorageChange, applyPanelAppStorageChange, panelAppStorageKey, panelAppStoragePath, preparePanelAppStorage, readPanelAppStorage, writePanelAppStorage, } from "./storage-store.js";
|
|
7
7
|
const MAX_READ_BYTES = 480 * 1024;
|
|
8
8
|
const MAX_WRITE_BYTES = 384 * 1024;
|
|
9
9
|
const MAX_LIST_ENTRIES = 200;
|
|
@@ -21,6 +21,8 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
21
21
|
]);
|
|
22
22
|
const METHOD_PERMISSIONS = {
|
|
23
23
|
"storage.get": "storage",
|
|
24
|
+
"storage.getSnapshot": "storage",
|
|
25
|
+
"storage.compareAndSet": "storage",
|
|
24
26
|
"storage.set": "storage",
|
|
25
27
|
"storage.delete": "storage",
|
|
26
28
|
"workspace.info": "workspace.info",
|
|
@@ -134,10 +136,15 @@ export class PanelRuntimeServices {
|
|
|
134
136
|
const key = panelAppStorageKey(params);
|
|
135
137
|
const file = panelAppStoragePath(this.dataDir, scope.appId, scope.projectPath);
|
|
136
138
|
const quota = DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES;
|
|
137
|
-
if (method === "storage.get") {
|
|
139
|
+
if (method === "storage.get" || method === "storage.getSnapshot") {
|
|
138
140
|
const state = await readPanelAppStorage(file, quota);
|
|
139
|
-
return
|
|
141
|
+
return method === "storage.getSnapshot"
|
|
142
|
+
? panelAppStorageSnapshot(state, key)
|
|
143
|
+
: Object.hasOwn(state, key)
|
|
144
|
+
? state[key]
|
|
145
|
+
: null;
|
|
140
146
|
}
|
|
147
|
+
const change = method === "storage.compareAndSet" ? panelAppStorageChange(params) : null;
|
|
141
148
|
const encoded = method === "storage.set" ? JSON.stringify(params?.value) : null;
|
|
142
149
|
if (method === "storage.set" && encoded === undefined)
|
|
143
150
|
throw new Error("Panel App storage only accepts JSON values");
|
|
@@ -147,6 +154,12 @@ export class PanelRuntimeServices {
|
|
|
147
154
|
const release = await storageLock(file);
|
|
148
155
|
try {
|
|
149
156
|
const state = await readPanelAppStorage(file, quota);
|
|
157
|
+
if (change) {
|
|
158
|
+
const result = applyPanelAppStorageChange(state, change);
|
|
159
|
+
if (result.updated)
|
|
160
|
+
await writePanelAppStorage(file, state, quota, () => authorized(scope));
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
150
163
|
const existed = Object.hasOwn(state, key);
|
|
151
164
|
if (method === "storage.set")
|
|
152
165
|
state[key] = JSON.parse(encoded);
|
package/dist/panels/runtime.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { type InstalledPanelApp } from "@cjhyy/code-shell-core";
|
|
3
|
-
import {
|
|
3
|
+
import type { SharedPanelToolHost } from "./shared-tool-jobs.js";
|
|
4
|
+
import { type PanelDirectoryAuthorizer } from "./directory-bookmarks.js";
|
|
4
5
|
import type { PanelSnapshot } from "./types.js";
|
|
5
|
-
|
|
6
|
+
import { type PanelAutomationHost } from "./automations.js";
|
|
7
|
+
export declare const panelWebCompatibility: (app: Pick<InstalledPanelApp, "permissions">, options?: {
|
|
8
|
+
automations?: boolean;
|
|
9
|
+
}) => {
|
|
10
|
+
supported: boolean;
|
|
11
|
+
reasons: string[];
|
|
12
|
+
};
|
|
6
13
|
export interface PanelTaskScope {
|
|
7
14
|
instanceId: string;
|
|
8
15
|
ownerId: string;
|
|
@@ -24,16 +31,21 @@ export interface PanelTaskHost {
|
|
|
24
31
|
export interface PanelRuntimeOptions {
|
|
25
32
|
cwd: string;
|
|
26
33
|
bindingCwd?: string;
|
|
34
|
+
projectPackages?: boolean;
|
|
27
35
|
dataDir: string;
|
|
28
36
|
host: "hub" | "desktop";
|
|
29
37
|
publicPathPrefix?: string;
|
|
30
|
-
snapshot: () => Promise<PanelSnapshot>;
|
|
38
|
+
snapshot: (appId?: string) => Promise<PanelSnapshot>;
|
|
31
39
|
ownerId: (request: IncomingMessage) => Promise<string | undefined>;
|
|
32
40
|
isAuthorized: (request: IncomingMessage) => Promise<boolean>;
|
|
33
41
|
/** Host/test seam. HTTP callers never provide install paths or this catalog. */
|
|
34
|
-
listInstalled?: () => Promise<InstalledPanelApp[]>;
|
|
42
|
+
listInstalled?: (appId?: string) => Promise<InstalledPanelApp[]>;
|
|
35
43
|
now?: () => number;
|
|
36
44
|
agentTasks?: PanelTaskHost;
|
|
45
|
+
automations?: PanelAutomationHost;
|
|
46
|
+
/** Reuse the Desktop coordinator; this transport never owns its lifetime. */
|
|
47
|
+
sharedToolJobs?: SharedPanelToolHost;
|
|
48
|
+
authorizePanelDirectory?: PanelDirectoryAuthorizer;
|
|
37
49
|
createAgentTasks?: (hooks: {
|
|
38
50
|
onPanelAction(scope: PanelTaskScope, input: Record<string, unknown>): Promise<unknown>;
|
|
39
51
|
}) => PanelTaskHost;
|