@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.
Files changed (109) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/clone.d.ts +13 -0
  51. package/dist/panels/resources/clone.js +99 -0
  52. package/dist/panels/resources/http.d.ts +11 -0
  53. package/dist/panels/resources/http.js +74 -0
  54. package/dist/panels/resources/references.d.ts +5 -0
  55. package/dist/panels/resources/references.js +26 -0
  56. package/dist/panels/resources/service.js +63 -27
  57. package/dist/panels/runtime-services.js +16 -3
  58. package/dist/panels/runtime.d.ts +16 -4
  59. package/dist/panels/runtime.js +522 -64
  60. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  61. package/dist/panels/shared-tool-jobs.js +37 -0
  62. package/dist/panels/storage-store.d.ts +21 -0
  63. package/dist/panels/storage-store.js +43 -0
  64. package/dist/panels/task-cookie-host.d.ts +26 -0
  65. package/dist/panels/task-cookie-host.js +145 -0
  66. package/dist/panels/task-cookies.d.ts +51 -0
  67. package/dist/panels/task-cookies.js +183 -0
  68. package/dist/panels/tool-executor.d.ts +21 -5
  69. package/dist/panels/tool-executor.js +171 -30
  70. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  71. package/dist/panels/tool-jobs-storage.js +34 -0
  72. package/dist/panels/tool-jobs.d.ts +49 -3
  73. package/dist/panels/tool-jobs.js +270 -15
  74. package/dist/panels/types.d.ts +43 -0
  75. package/dist/project-runtime/backup.d.ts +26 -0
  76. package/dist/project-runtime/backup.js +500 -0
  77. package/dist/project-runtime/control-server.d.ts +4 -0
  78. package/dist/project-runtime/control-server.js +19 -1
  79. package/dist/project-runtime/docker-provider.d.ts +4 -0
  80. package/dist/project-runtime/docker-provider.js +16 -1
  81. package/dist/project-runtime/managed-entry.js +4 -0
  82. package/dist/project-runtime/registry.d.ts +2 -0
  83. package/dist/project-runtime/registry.js +15 -0
  84. package/dist/project-runtime/seccomp.d.ts +8 -0
  85. package/dist/project-runtime/seccomp.js +72 -0
  86. package/dist/project-runtime/types.d.ts +2 -0
  87. package/dist/remote-relay/byte-stream.d.ts +9 -0
  88. package/dist/remote-relay/byte-stream.js +71 -0
  89. package/dist/remote-relay/connector.d.ts +27 -0
  90. package/dist/remote-relay/connector.js +197 -0
  91. package/dist/remote-relay/local-stream.d.ts +10 -0
  92. package/dist/remote-relay/local-stream.js +120 -0
  93. package/dist/remote-relay/protocol.d.ts +39 -0
  94. package/dist/remote-relay/protocol.js +64 -0
  95. package/dist/serve/automation-worker.d.ts +16 -0
  96. package/dist/serve/automation-worker.js +126 -0
  97. package/dist/serve/cli.d.ts +3 -0
  98. package/dist/serve/cli.js +17 -0
  99. package/dist/serve/headless-server.d.ts +3 -0
  100. package/dist/serve/headless-server.js +140 -10
  101. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  102. package/dist/serve/settings-recovery-cli.js +128 -0
  103. package/dist/session-http-responses.d.ts +7 -0
  104. package/dist/session-http-responses.js +40 -0
  105. package/dist/sessions-service.d.ts +6 -2
  106. package/dist/sessions-service.js +23 -4
  107. package/dist/worker-bridge-core.d.ts +5 -0
  108. package/dist/worker-bridge-core.js +23 -0
  109. 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
- finishExit({ code, signal });
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 = 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
- const temporary = directory.location(`.${randomUUID()}.resource-partial`);
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
- destination = await open(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
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
- const source = await this.openRead(scope, asset.id, { signal: context.signal });
154
- const hash = createHash("sha256");
155
- let bytes = 0;
156
- try {
157
- for await (const chunk of source.body) {
158
- await directory.verify();
159
- if (bytes + chunk.length > asset.bytes)
160
- throw new Error("Resource content changed");
161
- hash.update(chunk);
162
- let offset = 0;
163
- while (offset < chunk.length) {
164
- const written = await destination.write(chunk, offset, chunk.length - offset, bytes + offset);
165
- if (!written.bytesWritten)
166
- throw new Error("Resource copy stopped making progress");
167
- offset += written.bytesWritten;
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
- throw error;
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 Object.hasOwn(state, key) ? state[key] : null;
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);
@@ -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 { type PanelManagementOptions } from "./management.js";
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
- export declare const panelWebCompatibility: NonNullable<PanelManagementOptions["compatibility"]>;
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;