@cjhyy/code-shell-server 0.9.52 → 0.10.1

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 (85) hide show
  1. package/dist/account/cli.d.ts +23 -0
  2. package/dist/account/cli.js +249 -0
  3. package/dist/account/cloud-contract.d.ts +39 -0
  4. package/dist/account/cloud-contract.js +134 -0
  5. package/dist/account/cloud-types.d.ts +47 -0
  6. package/dist/account/cloud-types.js +1 -0
  7. package/dist/account/feature-service.d.ts +47 -0
  8. package/dist/account/feature-service.js +184 -0
  9. package/dist/account/http.d.ts +17 -0
  10. package/dist/account/http.js +158 -0
  11. package/dist/account/links.d.ts +13 -0
  12. package/dist/account/links.js +137 -0
  13. package/dist/account/manager.d.ts +96 -0
  14. package/dist/account/manager.js +976 -0
  15. package/dist/account/native-lock.d.ts +6 -0
  16. package/dist/account/native-lock.js +26 -0
  17. package/dist/account/native-store.d.ts +32 -0
  18. package/dist/account/native-store.js +162 -0
  19. package/dist/account/profile.d.ts +4 -0
  20. package/dist/account/profile.js +46 -0
  21. package/dist/account/store.d.ts +34 -0
  22. package/dist/account/store.js +118 -0
  23. package/dist/account/types.d.ts +138 -0
  24. package/dist/account/types.js +2 -0
  25. package/dist/desktop-web/http-api.d.ts +4 -0
  26. package/dist/desktop-web/http-api.js +12 -4
  27. package/dist/environment-identity.d.ts +1 -1
  28. package/dist/environment-identity.js +2 -1
  29. package/dist/headless-worker-env.d.ts +2 -0
  30. package/dist/headless-worker-env.js +15 -0
  31. package/dist/hub/approval-lease.js +6 -1
  32. package/dist/hub/run-replay.js +1 -1
  33. package/dist/hub/session-management.js +1 -1
  34. package/dist/index.account.d.ts +12 -0
  35. package/dist/index.account.js +12 -0
  36. package/dist/index.mobile-remote.d.ts +2 -0
  37. package/dist/index.mobile-remote.js +1 -0
  38. package/dist/index.worker.d.ts +1 -0
  39. package/dist/index.worker.js +1 -0
  40. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  41. package/dist/mobile-remote/account-relay-access.js +174 -0
  42. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  43. package/dist/mobile-remote/approval-clock.js +15 -0
  44. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  45. package/dist/mobile-remote/codex-room-agent.js +17 -1
  46. package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
  47. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  48. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  49. package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
  50. package/dist/mobile-remote/remote-host-manager.js +124 -17
  51. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  52. package/dist/mobile-remote/resident-agent.js +38 -11
  53. package/dist/mobile-remote/room-history.d.ts +7 -0
  54. package/dist/mobile-remote/room-history.js +56 -1
  55. package/dist/mobile-remote/room-manager.d.ts +216 -9
  56. package/dist/mobile-remote/room-manager.js +700 -65
  57. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  58. package/dist/mobile-remote/room-process-stop.js +41 -0
  59. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  60. package/dist/mobile-remote/trusted-device-store.js +54 -1
  61. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  62. package/dist/mobile-remote/turn-queue.js +422 -0
  63. package/dist/mobile-remote/types.d.ts +1 -1
  64. package/dist/panels/agent-task-host.js +6 -12
  65. package/dist/panels/agent-task-service.d.ts +2 -0
  66. package/dist/panels/agent-task-service.js +7 -0
  67. package/dist/panels/browser-capture.d.ts +1 -15
  68. package/dist/panels/browser-capture.js +1 -35
  69. package/dist/panels/http.d.ts +15 -15
  70. package/dist/panels/management-http.d.ts +9 -9
  71. package/dist/panels/process-service.d.ts +5 -4
  72. package/dist/panels/process-service.js +54 -16
  73. package/dist/panels/runtime.js +25 -14
  74. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  75. package/dist/panels/tool-jobs-storage.js +111 -34
  76. package/dist/panels/types.d.ts +1 -97
  77. package/dist/remote-relay/connector.d.ts +8 -0
  78. package/dist/remote-relay/connector.js +29 -2
  79. package/dist/remote-relay/protocol.d.ts +21 -0
  80. package/dist/remote-relay/protocol.js +31 -0
  81. package/dist/serve/cli.js +6 -0
  82. package/dist/serve/headless-server.js +24 -19
  83. package/dist/sessions-service.d.ts +4 -0
  84. package/dist/sessions-service.js +14 -1
  85. package/package.json +10 -6
@@ -26,9 +26,9 @@ export interface PanelAppProcessServiceOptions {
26
26
  /** Rechecked after asynchronous work and immediately before admitting a process. */
27
27
  isOwnerAuthorized?(owner: PanelProcessOwner): boolean | Promise<boolean>;
28
28
  /**
29
- * Desktop delegates durable approval lifetime to its Host hooks. Web uses guest
30
- * to isolate each authenticated instance; guest approvals never use the
31
- * app-wide durable hooks.
29
+ * When executable confirmation is required, app scope can delegate durable
30
+ * approval to Host hooks. Guest scope isolates authenticated instances and
31
+ * never uses the app-wide durable hooks.
32
32
  */
33
33
  approvalScope?: "app" | "guest";
34
34
  confirmExecution(input: {
@@ -40,7 +40,7 @@ export interface PanelAppProcessServiceOptions {
40
40
  }): Promise<boolean>;
41
41
  isExecutionApproved?(scope: PanelProcessApprovalScope): Promise<boolean>;
42
42
  rememberExecutionApproval?(scope: PanelProcessApprovalScope): Promise<void>;
43
- /** Desktop can use installed-app process permission and trusted-project binding as consent. */
43
+ /** Hosts can use installed-app process permission and authorized-project binding as consent. */
44
44
  allowAuthorizedProcessWithoutPrompt?: boolean;
45
45
  /** Resolve a declared entry in this app revision; return a canonical, reviewed file. */
46
46
  resolvePackageEntry?(owner: PanelProcessOwner, name: string): Promise<{
@@ -75,6 +75,7 @@ export declare class PanelAppProcessService {
75
75
  private readonly retiring;
76
76
  private readonly receipts;
77
77
  private readonly approvedExecutables;
78
+ private readonly pendingExecutionApprovals;
78
79
  private readonly guestEpochs;
79
80
  private readonly reservations;
80
81
  private closed;
@@ -169,6 +169,7 @@ export class PanelAppProcessService {
169
169
  retiring = new Map();
170
170
  receipts;
171
171
  approvedExecutables = new Set();
172
+ pendingExecutionApprovals = new Map();
172
173
  guestEpochs = new Map();
173
174
  reservations = new Map();
174
175
  closed = false;
@@ -447,6 +448,7 @@ export class PanelAppProcessService {
447
448
  // Admission is reserved before the first await, including a user's approval dialog.
448
449
  const releaseExecution = this.options.acquireExecution?.(owner);
449
450
  let processOwnsExecution = false;
451
+ let pendingApproval;
450
452
  this.reservations.set(owner.guestId, reserved + 1);
451
453
  try {
452
454
  await this.authorize(owner, epoch);
@@ -462,29 +464,45 @@ export class PanelAppProcessService {
462
464
  const guestScoped = this.options.approvalScope === "guest";
463
465
  const approvalKey = (guestScoped ? `${owner.guestId}\0${epoch}\0` : "") +
464
466
  `${scope.appId}\0${scope.revision}\0${scope.executablePath}\0${scope.executableFingerprint}`;
465
- // Desktop already checks installed-app process permission and a trusted,
466
- // bound project in authorize(). Web keeps its per-guest confirmation path.
467
+ // Hosts may use installed-app process permission and their authorized,
468
+ // bound project as consent; authorize() still runs before every spawn.
467
469
  let approved = this.options.allowAuthorizedProcessWithoutPrompt === true ||
468
470
  this.approvedExecutables.has(approvalKey);
469
471
  if (!approved && !guestScoped && this.options.isExecutionApproved) {
470
472
  approved = await this.options.isExecutionApproved(scope).catch(() => false);
471
473
  await this.authorize(owner, epoch);
472
474
  }
475
+ approved ||= this.approvedExecutables.has(approvalKey);
473
476
  if (!approved) {
474
- const allowed = await this.options.confirmExecution({
475
- guestId: owner.guestId,
476
- appId: owner.appId,
477
- appTitle: owner.appTitle,
478
- executable: executable.name,
479
- executablePath: executable.path,
480
- });
481
- await this.authorize(owner, epoch);
482
- if (!allowed)
483
- throw new Error(`User denied running ${executable.name}`);
484
- if (!guestScoped) {
485
- await this.options.rememberExecutionApproval?.(scope).catch(() => undefined);
486
- await this.authorize(owner, epoch);
477
+ let approval = this.pendingExecutionApprovals.get(approvalKey);
478
+ if (!approval) {
479
+ approval = {
480
+ guestId: owner.guestId,
481
+ waiters: 0,
482
+ result: (async () => {
483
+ const allowed = await this.options.confirmExecution({
484
+ guestId: owner.guestId,
485
+ appId: owner.appId,
486
+ appTitle: owner.appTitle,
487
+ executable: executable.name,
488
+ executablePath: executable.path,
489
+ });
490
+ await this.authorize(owner, epoch);
491
+ if (!allowed)
492
+ throw new Error(`User denied running ${executable.name}`);
493
+ if (!guestScoped) {
494
+ await this.options.rememberExecutionApproval?.(scope).catch(() => undefined);
495
+ await this.authorize(owner, epoch);
496
+ }
497
+ })(),
498
+ };
499
+ this.pendingExecutionApprovals.set(approvalKey, approval);
487
500
  }
501
+ approval.waiters++;
502
+ pendingApproval = { key: approvalKey, approval };
503
+ await approval.result;
504
+ // Sharing consent does not share a caller's authority or process handles.
505
+ await this.authorize(owner, epoch);
488
506
  }
489
507
  // A binary replaced while a dialog was open must earn a new approval.
490
508
  if ((await executableFingerprint(executable.path)) !== scope.executableFingerprint) {
@@ -618,6 +636,13 @@ export class PanelAppProcessService {
618
636
  }),
619
637
  };
620
638
  this.processes.set(processId, running);
639
+ // A successful launcher may leave children behind (including children
640
+ // retaining stdout). Reap its group on leader exit, before close/lease release.
641
+ child.once("exit", () => {
642
+ const pid = child.pid;
643
+ if (!running.termination && typeof pid === "number" && pid > 1)
644
+ running.termination = killProcessGroup(pid).catch(() => { });
645
+ });
621
646
  if (modelTraceOwner && this.options.modelTraces) {
622
647
  const channel = new PanelModelTraceChannel({
623
648
  service: this.options.modelTraces,
@@ -668,7 +693,7 @@ export class PanelAppProcessService {
668
693
  clearTimeout(lifetime);
669
694
  if (inputChecks)
670
695
  clearInterval(inputChecks);
671
- // Cancellation also waits for the process group, not just its leader.
696
+ // Natural exit and cancellation both wait for the entire process group.
672
697
  await running.termination;
673
698
  let finalCode = code;
674
699
  if (inputChecks && this.processes.get(processId) === running) {
@@ -704,6 +729,14 @@ export class PanelAppProcessService {
704
729
  return { processId, executable: executable.name };
705
730
  }
706
731
  finally {
732
+ if (pendingApproval) {
733
+ const { key, approval } = pendingApproval;
734
+ approval.waiters--;
735
+ // Keep successful consent in flight until a caller has cached it after
736
+ // all spawn checks. Rejections disappear once every waiter has observed them.
737
+ if (!approval.waiters && this.pendingExecutionApprovals.get(key) === approval)
738
+ this.pendingExecutionApprovals.delete(key);
739
+ }
707
740
  if (!processOwnsExecution)
708
741
  releaseExecution?.();
709
742
  const remaining = (this.reservations.get(owner.guestId) ?? 1) - 1;
@@ -874,6 +907,10 @@ export class PanelAppProcessService {
874
907
  }
875
908
  revokeGuest(guestId) {
876
909
  this.guestEpochs.set(guestId, (this.guestEpochs.get(guestId) ?? 0) + 1);
910
+ for (const [key, approval] of this.pendingExecutionApprovals) {
911
+ if (approval.guestId === guestId)
912
+ this.pendingExecutionApprovals.delete(key);
913
+ }
877
914
  if (this.options.approvalScope === "guest") {
878
915
  for (const key of this.approvedExecutables) {
879
916
  if (key.startsWith(`${guestId}\0`))
@@ -938,6 +975,7 @@ export class PanelAppProcessService {
938
975
  for (const guestId of guests)
939
976
  this.revokeGuest(guestId);
940
977
  this.approvedExecutables.clear();
978
+ this.pendingExecutionApprovals.clear();
941
979
  this.receipts.close();
942
980
  }
943
981
  terminate(running) {
@@ -412,6 +412,14 @@ function injectBridge(html, source) {
412
412
  const offset = doctype?.[0].length ?? 0;
413
413
  return html.slice(0, offset) + script + html.slice(offset);
414
414
  }
415
+ // CSP3 and Chromium resolve 'self' in a sandboxed document to its URL's origin,
416
+ // but WebKit (every iOS browser) matches nothing, blocking all of a panel's own
417
+ // scripts and styles. Spell that origin out so every engine enforces one policy.
418
+ function resolveMetaPolicySelf(html, origin) {
419
+ return html.replace(/<meta\b[^>]*>/gi, (tag) => /\shttp-equiv\s*=\s*(["']?)content-security-policy\1/i.test(tag)
420
+ ? tag.replaceAll("'self'", origin)
421
+ : tag);
422
+ }
415
423
  /** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
416
424
  export function createPanelRuntime(options) {
417
425
  const publicPathPrefix = options.publicPathPrefix ?? "";
@@ -557,6 +565,9 @@ export function createPanelRuntime(options) {
557
565
  holder: { kind: "process" },
558
566
  }),
559
567
  approvalScope: "guest",
568
+ // Match Desktop: installed process permission and the authorized project binding
569
+ // are consent. Every spawn still rechecks owner/revision, handles and fingerprints.
570
+ allowAuthorizedProcessWithoutPrompt: true,
560
571
  resolvePackageEntry: async (owner, name) => {
561
572
  const taskScope = toolOwners.get(owner.guestId);
562
573
  if (taskScope) {
@@ -583,13 +594,7 @@ export function createPanelRuntime(options) {
583
594
  const grant = byGuest.get(owner.guestId);
584
595
  return !!grant && (await authorized(grant));
585
596
  },
586
- confirmExecution: async (input) => {
587
- if (toolOwners.has(input.guestId))
588
- return true;
589
- const grant = byGuest.get(input.guestId);
590
- return (!!grant &&
591
- (await confirm(grant, `允许 ${input.appTitle} 运行 ${input.executable}?`, `程序将在服务端运行,可使用当前运行用户的文件与网络权限。\n程序:${input.executablePath}`)));
592
- },
597
+ confirmExecution: async () => true,
593
598
  });
594
599
  const resources = new PanelResourceService({
595
600
  rootDirectory: join(options.dataDir, "panel-app-media"),
@@ -1360,6 +1365,7 @@ export function createPanelRuntime(options) {
1360
1365
  instanceId: id,
1361
1366
  src: ASSETS + asset + "/" + app.entry.split("/").map(encodeURIComponent).join("/"),
1362
1367
  expiresAt: grant.expiresAt,
1368
+ serverTime: now(),
1363
1369
  context,
1364
1370
  limitations: panel.compatibility.reasons,
1365
1371
  };
@@ -1595,9 +1601,8 @@ export function createPanelRuntime(options) {
1595
1601
  if (method.startsWith("agent.task.")) {
1596
1602
  if (!agentTasks)
1597
1603
  error(501, "当前宿主未提供独立面板任务。");
1598
- if (method === "agent.task.start" &&
1599
- !(await confirm(grant, `启动 ${grant.app.title.default} 的 AI 任务?`, String(params?.prompt ?? "").slice(0, 8000))))
1600
- error(403, "你取消了面板 AI 任务。");
1604
+ // Match Desktop: the installed app's agent.task permission and live project
1605
+ // binding authorize task creation. Worker tool approvals stay independent.
1601
1606
  return agentTasks.call(taskScope(grant), method, params);
1602
1607
  }
1603
1608
  if (method.startsWith("process.") || method.startsWith("filesystem.")) {
@@ -1904,7 +1909,7 @@ export function createPanelRuntime(options) {
1904
1909
  if (!(await authorized(grant)))
1905
1910
  error(410, "面板授权已失效。");
1906
1911
  grant.expiresAt = now() + GRANT_TTL;
1907
- json(response, 200, { expiresAt: grant.expiresAt });
1912
+ json(response, 200, { expiresAt: grant.expiresAt, serverTime: now() });
1908
1913
  }
1909
1914
  else if (request.method === "GET" && match[2] === "events") {
1910
1915
  const after = Number(url.searchParams.get("after") ?? 0);
@@ -1913,7 +1918,11 @@ export function createPanelRuntime(options) {
1913
1918
  grant.events = grant.events.filter((entry) => entry.id > after);
1914
1919
  grant.eventBytes = grant.events.reduce((total, entry) => total + Buffer.byteLength(JSON.stringify(entry.payload) ?? "null"), 0);
1915
1920
  const batch = grant.events.slice(0, 128);
1916
- json(response, 200, { events: batch, cursor: batch.at(-1)?.id ?? after });
1921
+ json(response, 200, {
1922
+ events: batch,
1923
+ cursor: batch.at(-1)?.id ?? after,
1924
+ serverTime: now(),
1925
+ });
1917
1926
  }
1918
1927
  else if (request.method === "POST" && match[2] === "directory-picker") {
1919
1928
  if (request.headers.origin !== grant.origin)
@@ -2091,12 +2100,14 @@ export function createPanelRuntime(options) {
2091
2100
  // including when the public site is behind HTTPS termination.
2092
2101
  const source = publicPathPrefix + ASSETS + grant.asset + "/";
2093
2102
  if (extname(path).toLowerCase() === ".html") {
2094
- bytes = Buffer.from(injectBridge(bytes.toString("utf8"), source));
2095
2103
  const resourceOrigin = grant.origin;
2104
+ bytes = Buffer.from(resolveMetaPolicySelf(injectBridge(bytes.toString("utf8"), source), resourceOrigin));
2096
2105
  const assetSource = resourceOrigin + source;
2097
2106
  // Panels export their own generated files (for example JSON backups).
2098
2107
  // Download permission does not grant origin access, network fetches or popups.
2099
2108
  // The embedding iframe must enable the same permission.
2109
+ // Forms need submission events and method=dialog for local UI. The
2110
+ // enforcing form-action policy still blocks every form navigation.
2100
2111
  response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
2101
2112
  assetSource +
2102
2113
  " 'wasm-unsafe-eval'; style-src " +
@@ -2109,7 +2120,7 @@ export function createPanelRuntime(options) {
2109
2120
  assetSource +
2110
2121
  " data:; connect-src " +
2111
2122
  assetSource +
2112
- "; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads; frame-ancestors " +
2123
+ "; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads allow-forms; frame-ancestors " +
2113
2124
  resourceOrigin);
2114
2125
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
2115
2126
  }
@@ -5,9 +5,13 @@ export declare class ToolJobStorage {
5
5
  private root;
6
6
  private identity;
7
7
  private nonce;
8
+ private ownerClaimFile;
8
9
  private readonly directories;
9
10
  constructor(configuredRoot: string);
10
11
  initialize(): Promise<void>;
12
+ private assertDeadOwner;
13
+ private acquireOwnerClaim;
14
+ private releaseOwnerClaim;
11
15
  directory(id?: string, work?: boolean, create?: boolean): Promise<string>;
12
16
  read(path: string, limit: number): Promise<unknown>;
13
17
  write(id: string, value: unknown, limit: number): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { lstat, mkdir, open, realpath, readdir, rename, rm, rmdir, unlink } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join } from "node:path";
5
5
  export function toolJobJson(value, limit) {
6
6
  let text;
@@ -20,6 +20,7 @@ export class ToolJobStorage {
20
20
  root = "";
21
21
  identity = "";
22
22
  nonce = "";
23
+ ownerClaimFile = "";
23
24
  directories = new Map();
24
25
  constructor(configuredRoot) {
25
26
  this.configuredRoot = configuredRoot;
@@ -33,43 +34,114 @@ export class ToolJobStorage {
33
34
  throw new Error("invalid tool job root");
34
35
  this.root = await realpath(this.configuredRoot);
35
36
  this.identity = `${initial.dev}:${initial.ino}`;
36
- const lockPath = join(this.root, "owner.lock");
37
- const create = () => open(lockPath, "wx", 0o600);
38
- let lock;
37
+ this.nonce = randomUUID();
39
38
  try {
40
- lock = await create();
39
+ await this.acquireOwnerClaim();
40
+ // Keep the previous file format as an interoperability barrier for older
41
+ // Hosts. All current Hosts hold the atomic directory claim before touching it.
42
+ const lockPath = join(this.root, "owner.lock");
43
+ try {
44
+ const current = (await this.read(lockPath, 1024));
45
+ this.assertDeadOwner(current.pid);
46
+ await rm(lockPath);
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "ENOENT")
50
+ throw error;
51
+ }
52
+ const lock = await open(lockPath, "wx", 0o600);
53
+ try {
54
+ await lock.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
55
+ await lock.sync();
56
+ }
57
+ finally {
58
+ await lock.close();
59
+ }
41
60
  }
42
61
  catch (error) {
43
- if (error.code !== "EEXIST")
44
- throw error;
45
- const current = (await this.read(lockPath, 1024));
46
- let alive = false;
47
- if (Number.isSafeInteger(current.pid) && Number(current.pid) > 1) {
48
- try {
49
- process.kill(Number(current.pid), 0);
50
- alive = true;
62
+ await this.releaseOwnerClaim();
63
+ this.nonce = "";
64
+ throw error;
65
+ }
66
+ }
67
+ assertDeadOwner(pid) {
68
+ if (!Number.isSafeInteger(pid) || Number(pid) <= 1) {
69
+ throw new Error("invalid tool job ownership lock");
70
+ }
71
+ try {
72
+ process.kill(Number(pid), 0);
73
+ }
74
+ catch (error) {
75
+ if (error.code === "ESRCH")
76
+ return;
77
+ }
78
+ throw new Error("tool job storage is already owned by a running Host");
79
+ }
80
+ async acquireOwnerClaim() {
81
+ const destination = join(this.root, "owner.claim");
82
+ const temporary = join(this.root, `.owner-${this.nonce}`);
83
+ const marker = `${this.nonce}.json`;
84
+ await mkdir(temporary, { mode: 0o700 });
85
+ try {
86
+ const file = await open(join(temporary, marker), "wx", 0o600);
87
+ try {
88
+ await file.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
89
+ await file.sync();
90
+ }
91
+ finally {
92
+ await file.close();
93
+ }
94
+ try {
95
+ await rename(temporary, destination);
96
+ }
97
+ catch (error) {
98
+ if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code ?? ""))
99
+ throw error;
100
+ const directory = await lstat(destination);
101
+ if (!directory.isDirectory() || directory.isSymbolicLink())
102
+ throw new Error("invalid tool job ownership claim", { cause: error });
103
+ const entries = await readdir(destination);
104
+ if (entries.length > 1 || entries.some((entry) => !/^[a-f0-9-]{36}\.json$/.test(entry))) {
105
+ throw new Error("invalid tool job ownership claim", { cause: error });
51
106
  }
52
- catch (cause) {
53
- if (cause.code !== "ESRCH")
54
- alive = true;
107
+ if (entries[0]) {
108
+ const stalePath = join(destination, entries[0]);
109
+ try {
110
+ const current = (await this.read(stalePath, 1024));
111
+ this.assertDeadOwner(current.pid);
112
+ // Delete only the observed owner's unique marker. A delayed
113
+ // reclaimer cannot unlink a new owner's different marker.
114
+ await unlink(stalePath);
115
+ }
116
+ catch (cause) {
117
+ if (cause.code !== "ENOENT")
118
+ throw cause;
119
+ }
55
120
  }
121
+ // Never move the existing claim: a replacement may already own it.
122
+ // Some platforms cannot replace even an empty destination directory.
123
+ // rmdir is safe here: it cannot remove a newly published nonempty claim.
124
+ await rmdir(destination).catch((cause) => {
125
+ if (cause.code !== "ENOENT")
126
+ throw cause;
127
+ });
128
+ await rename(temporary, destination);
56
129
  }
57
- else
58
- throw new Error("invalid tool job ownership lock", { cause: error });
59
- if (alive)
60
- throw new Error("tool job storage is already owned by a running Host", { cause: error });
61
- await rm(lockPath);
62
- lock = await create();
63
- }
64
- this.nonce = randomUUID();
65
- try {
66
- await lock.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
67
- await lock.sync();
130
+ this.ownerClaimFile = join(destination, marker);
68
131
  }
69
132
  finally {
70
- await lock.close();
133
+ await rm(temporary, { recursive: true, force: true });
71
134
  }
72
135
  }
136
+ async releaseOwnerClaim() {
137
+ if (!this.ownerClaimFile)
138
+ return;
139
+ await unlink(this.ownerClaimFile).catch((error) => {
140
+ if (error.code !== "ENOENT")
141
+ throw error;
142
+ });
143
+ this.ownerClaimFile = "";
144
+ }
73
145
  async directory(id, work = false, create = false) {
74
146
  const info = await lstat(this.root);
75
147
  if (!info.isDirectory() ||
@@ -186,12 +258,17 @@ export class ToolJobStorage {
186
258
  async close() {
187
259
  if (!this.nonce)
188
260
  return;
189
- const path = join(await this.directory(), "owner.lock");
190
- const lock = (await this.read(path, 1024));
191
- if (lock.nonce === this.nonce)
192
- await rm(path);
193
- this.nonce = "";
194
- this.directories.clear();
261
+ try {
262
+ const path = join(await this.directory(), "owner.lock");
263
+ const lock = (await this.read(path, 1024));
264
+ if (lock.nonce === this.nonce)
265
+ await rm(path);
266
+ }
267
+ finally {
268
+ await this.releaseOwnerClaim();
269
+ this.nonce = "";
270
+ this.directories.clear();
271
+ }
195
272
  }
196
273
  async remove(id) {
197
274
  const path = await this.directory(id);
@@ -1,97 +1 @@
1
- import type { GitPanelAppDiscoveryCandidate, GitPanelAppDiscoveryIssue, PanelAppPreview } from "@cjhyy/code-shell-core";
2
- export interface PanelCompatibility {
3
- supported: boolean;
4
- reasons: string[];
5
- }
6
- export interface PanelGitSource {
7
- kind: "git";
8
- url: string;
9
- /** The original branch/tag, retained separately from the pinned archive commit. */
10
- ref: string;
11
- subdir?: string;
12
- commit: string;
13
- }
14
- export interface ManagedPanel extends Omit<PanelAppPreview, "reviewToken" | "alreadyInstalled" | "warnings" | "source"> {
15
- packageDigest?: string;
16
- revision: string;
17
- bound: boolean;
18
- enabled: boolean;
19
- globalDisabled: boolean;
20
- updatable: boolean;
21
- source: {
22
- kind: "git" | "local";
23
- label: string;
24
- url?: string;
25
- ref?: string;
26
- commit?: string;
27
- subdir?: string;
28
- };
29
- compatibility: PanelCompatibility;
30
- }
31
- export interface PanelPackageIssue {
32
- id: string;
33
- revision: string;
34
- code: "package_unavailable";
35
- version?: string;
36
- packageDigest?: string;
37
- bound: boolean;
38
- globalDisabled: boolean;
39
- }
40
- export interface PanelSnapshot {
41
- panels: ManagedPanel[];
42
- issues?: PanelPackageIssue[];
43
- workspace: string;
44
- hasProject: boolean;
45
- canRestorePackages?: boolean;
46
- }
47
- export interface PanelDiscovery {
48
- panels: GitPanelAppDiscoveryCandidate[];
49
- issues: GitPanelAppDiscoveryIssue[];
50
- source: PanelGitSource;
51
- }
52
- export interface PanelReview {
53
- reviewToken: string;
54
- expiresAt: number;
55
- kind: "install" | "update";
56
- preview: Omit<PanelAppPreview, "reviewToken">;
57
- source: PanelGitSource;
58
- expectedRevision: string | null;
59
- compatibility: PanelCompatibility;
60
- /** The installed package selected for this project, when its manifest is available. */
61
- installedVersion?: string;
62
- /** Reviewed permissions of that same installed package; an empty array is a known baseline. */
63
- installedPermissions?: PanelAppPreview["permissions"];
64
- }
65
- /** Trusted native hosts may review local sources without exposing paths to Web routes. */
66
- export type PanelProjectReview = Omit<PanelReview, "source">;
67
- export interface PanelOperationContext {
68
- ownerId: string;
69
- authorize: () => boolean | Promise<boolean>;
70
- }
71
- export interface PanelPackageVersion {
72
- version: string;
73
- packageDigest: string;
74
- permissions: PanelAppPreview["permissions"];
75
- compatibility: PanelCompatibility;
76
- }
77
- export interface PanelPackageHistory {
78
- appId: string;
79
- title: PanelAppPreview["title"];
80
- expectedRevision: string;
81
- current: {
82
- version: string;
83
- packageDigest?: string;
84
- unavailable?: boolean;
85
- };
86
- versions: PanelPackageVersion[];
87
- unavailablePackages: number;
88
- }
89
- export interface PanelPackageRestoreReview extends PanelPackageVersion {
90
- appId: string;
91
- title: PanelAppPreview["title"];
92
- current: PanelPackageHistory["current"];
93
- addedPermissions: PanelAppPreview["permissions"];
94
- expectedRevision: string;
95
- reviewToken: string;
96
- expiresAt: number;
97
- }
1
+ export type { PanelCompatibility, PanelGitSource, ManagedPanel, PanelPackageIssue, PanelSnapshot, PanelDiscovery, PanelReview, PanelProjectReview, PanelOperationContext, PanelPackageVersion, PanelPackageHistory, PanelPackageRestoreReview, } from "@cjhyy/code-shell-web/panels";
@@ -19,6 +19,14 @@ export interface DeviceRelayConnectorOptions {
19
19
  /** Additional trusted CA for private installations; TLS verification stays enabled. */
20
20
  ca?: ConnectionOptions["ca"];
21
21
  onState?: (state: DeviceRelayState) => void;
22
+ /**
23
+ * Opt-in: lets the directory mint a one-time phone pairing token for its
24
+ * owner. Omitted, the feature is neither advertised nor accepted.
25
+ */
26
+ issuePairing?: () => {
27
+ token: string;
28
+ expiresAt: number;
29
+ };
22
30
  }
23
31
  export interface DeviceRelayConnector {
24
32
  /** Idempotent while running. Callers must await close before restarting. */
@@ -1,12 +1,13 @@
1
1
  import { WebSocket } from "ws";
2
2
  import { openRelayStream } from "./local-stream.js";
3
- import { isRelayHostId, isRelayOrigin, isRelayToken, parseRelayControlMessage, RELAY_CONNECT_PATH, RELAY_CONTROL_MAX_BYTES, RELAY_MAX_STREAMS, RELAY_PING_INTERVAL_MS, RELAY_PONG_TIMEOUT_MS, RELAY_SETUP_TIMEOUT_MS, } from "./protocol.js";
3
+ import { isRelayHostId, isRelayOrigin, isRelayToken, parseRelayControlMessage, RELAY_CONNECT_PATH, RELAY_CONTROL_MAX_BYTES, RELAY_FEATURE_PAIRING, RELAY_FEATURES_HEADER, RELAY_MAX_STREAMS, RELAY_PING_INTERVAL_MS, RELAY_PONG_TIMEOUT_MS, RELAY_SETUP_TIMEOUT_MS, } from "./protocol.js";
4
4
  export function createDeviceRelayConnector(options) {
5
5
  if (!isRelayOrigin(options.relayOrigin) ||
6
6
  !isRelayOrigin(options.publicOrigin) ||
7
7
  !isRelayHostId(options.hostId) ||
8
8
  (!options.getCredential && !isRelayToken(options.credential)) ||
9
9
  (options.getCredential !== undefined && typeof options.getCredential !== "function") ||
10
+ (options.issuePairing !== undefined && typeof options.issuePairing !== "function") ||
10
11
  !options.localHost ||
11
12
  !Number.isInteger(options.localHost.port) ||
12
13
  options.localHost.port < 1 ||
@@ -96,7 +97,10 @@ export function createDeviceRelayConnector(options) {
96
97
  const url = new URL(RELAY_CONNECT_PATH, config.relayOrigin);
97
98
  url.protocol = "wss:";
98
99
  const ws = new WebSocket(url, {
99
- headers: { Authorization: `Bearer ${credential}` },
100
+ headers: {
101
+ Authorization: `Bearer ${credential}`,
102
+ ...(config.issuePairing ? { [RELAY_FEATURES_HEADER]: RELAY_FEATURE_PAIRING } : {}),
103
+ },
100
104
  ca: config.ca,
101
105
  followRedirects: false,
102
106
  perMessageDeflate: false,
@@ -180,6 +184,29 @@ export function createDeviceRelayConnector(options) {
180
184
  state("ready");
181
185
  return;
182
186
  }
187
+ if (message.type === "pair") {
188
+ if (!config.issuePairing || !leaseId || message.leaseId !== leaseId)
189
+ return ws.terminate();
190
+ let reply = {
191
+ type: "pair-failed",
192
+ v: 1,
193
+ leaseId,
194
+ requestId: message.requestId,
195
+ };
196
+ try {
197
+ const issued = config.issuePairing();
198
+ if (isRelayToken(issued?.token) &&
199
+ Number.isSafeInteger(issued.expiresAt) &&
200
+ issued.expiresAt > Date.now())
201
+ reply = { ...reply, type: "paired", token: issued.token, expiresAt: issued.expiresAt };
202
+ }
203
+ catch {
204
+ /* Refused or unavailable: the directory reports failure to its owner. */
205
+ }
206
+ if (current() && ws.readyState === ws.OPEN)
207
+ ws.send(JSON.stringify(reply));
208
+ return;
209
+ }
183
210
  if (message.type !== "open" ||
184
211
  !leaseId ||
185
212
  message.leaseId !== leaseId ||