@cjhyy/code-shell-server 0.9.22 → 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.
Files changed (104) 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/http.d.ts +11 -0
  51. package/dist/panels/resources/http.js +74 -0
  52. package/dist/panels/resources/references.d.ts +5 -0
  53. package/dist/panels/resources/references.js +26 -0
  54. package/dist/panels/runtime-services.js +16 -3
  55. package/dist/panels/runtime.d.ts +16 -4
  56. package/dist/panels/runtime.js +522 -64
  57. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  58. package/dist/panels/shared-tool-jobs.js +37 -0
  59. package/dist/panels/storage-store.d.ts +21 -0
  60. package/dist/panels/storage-store.js +43 -0
  61. package/dist/panels/task-cookie-host.d.ts +26 -0
  62. package/dist/panels/task-cookie-host.js +145 -0
  63. package/dist/panels/task-cookies.d.ts +51 -0
  64. package/dist/panels/task-cookies.js +183 -0
  65. package/dist/panels/tool-executor.d.ts +21 -5
  66. package/dist/panels/tool-executor.js +171 -30
  67. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  68. package/dist/panels/tool-jobs-storage.js +34 -0
  69. package/dist/panels/tool-jobs.d.ts +49 -3
  70. package/dist/panels/tool-jobs.js +270 -15
  71. package/dist/panels/types.d.ts +43 -0
  72. package/dist/project-runtime/backup.d.ts +26 -0
  73. package/dist/project-runtime/backup.js +500 -0
  74. package/dist/project-runtime/control-server.d.ts +4 -0
  75. package/dist/project-runtime/control-server.js +19 -1
  76. package/dist/project-runtime/docker-provider.d.ts +4 -0
  77. package/dist/project-runtime/docker-provider.js +16 -1
  78. package/dist/project-runtime/managed-entry.js +4 -0
  79. package/dist/project-runtime/registry.d.ts +2 -0
  80. package/dist/project-runtime/registry.js +15 -0
  81. package/dist/project-runtime/seccomp.d.ts +8 -0
  82. package/dist/project-runtime/seccomp.js +72 -0
  83. package/dist/project-runtime/types.d.ts +2 -0
  84. package/dist/remote-relay/byte-stream.d.ts +9 -0
  85. package/dist/remote-relay/byte-stream.js +71 -0
  86. package/dist/remote-relay/connector.d.ts +27 -0
  87. package/dist/remote-relay/connector.js +197 -0
  88. package/dist/remote-relay/local-stream.d.ts +10 -0
  89. package/dist/remote-relay/local-stream.js +120 -0
  90. package/dist/remote-relay/protocol.d.ts +39 -0
  91. package/dist/remote-relay/protocol.js +64 -0
  92. package/dist/serve/automation-worker.d.ts +16 -0
  93. package/dist/serve/automation-worker.js +126 -0
  94. package/dist/serve/cli.d.ts +3 -0
  95. package/dist/serve/cli.js +17 -0
  96. package/dist/serve/headless-server.d.ts +3 -0
  97. package/dist/serve/headless-server.js +140 -10
  98. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  99. package/dist/serve/settings-recovery-cli.js +128 -0
  100. package/dist/session-http-responses.d.ts +7 -0
  101. package/dist/session-http-responses.js +40 -0
  102. package/dist/worker-bridge-core.d.ts +5 -0
  103. package/dist/worker-bridge-core.js +23 -0
  104. 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 tunnel = options.mode === "tunnel";
115
- this.passcode = tunnel ? options.passcode : undefined;
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: tunnel ? undefined : this.mobileDevUrl,
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 = tunnel
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: tunnel ? "tunnel" : "lan",
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
- this.emitChanged(task.owner, publicTask(task));
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?: Readonly<{
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
- }> | undefined;
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 ? { process: processLimits } : {}),
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
- constructor(file: string);
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
- constructor(file) {
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) => !(item.appId === appId && item.projectPath === projectPath && item.path === path)),
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
- const bookmark = read(this.file).find((item) => item.id === id && item.appId === appId && item.projectPath === projectPath);
125
- if (!bookmark)
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();