@cjhyy/code-shell-server 0.9.51 → 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 (93) 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/configuration.d.ts +11 -0
  33. package/dist/hub/configuration.js +34 -1
  34. package/dist/hub/model-parameter-metadata.d.ts +26 -0
  35. package/dist/hub/model-parameter-metadata.js +92 -0
  36. package/dist/hub/run-replay.js +1 -1
  37. package/dist/hub/session-management.js +1 -1
  38. package/dist/index.account.d.ts +12 -0
  39. package/dist/index.account.js +12 -0
  40. package/dist/index.mobile-remote.d.ts +2 -0
  41. package/dist/index.mobile-remote.js +1 -0
  42. package/dist/index.worker.d.ts +1 -0
  43. package/dist/index.worker.js +1 -0
  44. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  45. package/dist/mobile-remote/account-relay-access.js +174 -0
  46. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  47. package/dist/mobile-remote/approval-clock.js +15 -0
  48. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  49. package/dist/mobile-remote/codex-room-agent.js +17 -1
  50. package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
  51. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  52. package/dist/mobile-remote/mobile-client-event-validator.js +47 -2
  53. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  54. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  55. package/dist/mobile-remote/remote-host-manager.d.ts +21 -4
  56. package/dist/mobile-remote/remote-host-manager.js +142 -25
  57. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  58. package/dist/mobile-remote/resident-agent.js +38 -11
  59. package/dist/mobile-remote/room-history.d.ts +7 -0
  60. package/dist/mobile-remote/room-history.js +56 -1
  61. package/dist/mobile-remote/room-manager.d.ts +216 -9
  62. package/dist/mobile-remote/room-manager.js +700 -65
  63. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  64. package/dist/mobile-remote/room-process-stop.js +41 -0
  65. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  66. package/dist/mobile-remote/trusted-device-store.js +54 -1
  67. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  68. package/dist/mobile-remote/turn-queue.js +422 -0
  69. package/dist/mobile-remote/types.d.ts +1 -1
  70. package/dist/panels/agent-task-host.js +6 -12
  71. package/dist/panels/agent-task-service.d.ts +2 -0
  72. package/dist/panels/agent-task-service.js +7 -0
  73. package/dist/panels/browser-capture.d.ts +1 -15
  74. package/dist/panels/browser-capture.js +1 -35
  75. package/dist/panels/directory-picker.d.ts +48 -0
  76. package/dist/panels/directory-picker.js +247 -0
  77. package/dist/panels/http.d.ts +15 -15
  78. package/dist/panels/management-http.d.ts +9 -9
  79. package/dist/panels/process-service.d.ts +5 -4
  80. package/dist/panels/process-service.js +54 -16
  81. package/dist/panels/runtime.js +148 -30
  82. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  83. package/dist/panels/tool-jobs-storage.js +111 -34
  84. package/dist/panels/types.d.ts +1 -97
  85. package/dist/remote-relay/connector.d.ts +8 -0
  86. package/dist/remote-relay/connector.js +29 -2
  87. package/dist/remote-relay/protocol.d.ts +21 -0
  88. package/dist/remote-relay/protocol.js +31 -0
  89. package/dist/serve/cli.js +6 -0
  90. package/dist/serve/headless-server.js +24 -19
  91. package/dist/sessions-service.d.ts +4 -0
  92. package/dist/sessions-service.js +14 -1
  93. package/package.json +10 -6
@@ -18,6 +18,7 @@ import { PanelTaskCookieHost } from "./task-cookie-host.js";
18
18
  import { taskCookieFromInput, taskCookieSelection, } from "./task-cookies.js";
19
19
  import { desktopPanelDirectoryBookmarks, hubPanelDirectoryBookmarks, } from "./directory-bookmarks.js";
20
20
  import { handlePanelProcessDirectory } from "./process-files.js";
21
+ import { PanelDirectoryPicker, capturePanelWorkspaceDirectory, directoryPickerLifetime, } from "./directory-picker.js";
21
22
  import { PanelAppProcessService, panelExecutableDirectories, panelProcessInfo, } from "./process-service.js";
22
23
  import { PanelManagementError } from "./management.js";
23
24
  import { panelAutomationMethods } from "./automations.js";
@@ -305,7 +306,7 @@ function bridgeScript(id, origin) {
305
306
  const requestId = String(++next);
306
307
  const timer = setTimeout(() => {
307
308
  pending.delete(requestId); reject(new Error("Panel request timed out"));
308
- }, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio", "resources.recordVideo"].includes(method) ? 30 * 60 * 1000 : 60000);
309
+ }, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio", "resources.recordVideo"].includes(method) ? 30 * 60 * 1000 : method === "filesystem.pickDirectory" ? 6 * 60 * 1000 : 60000);
309
310
  pending.set(requestId, { resolve, reject, timer });
310
311
  try {
311
312
  parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
@@ -411,6 +412,14 @@ function injectBridge(html, source) {
411
412
  const offset = doctype?.[0].length ?? 0;
412
413
  return html.slice(0, offset) + script + html.slice(offset);
413
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
+ }
414
423
  /** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
415
424
  export function createPanelRuntime(options) {
416
425
  const publicPathPrefix = options.publicPathPrefix ?? "";
@@ -556,6 +565,9 @@ export function createPanelRuntime(options) {
556
565
  holder: { kind: "process" },
557
566
  }),
558
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,
559
571
  resolvePackageEntry: async (owner, name) => {
560
572
  const taskScope = toolOwners.get(owner.guestId);
561
573
  if (taskScope) {
@@ -582,13 +594,7 @@ export function createPanelRuntime(options) {
582
594
  const grant = byGuest.get(owner.guestId);
583
595
  return !!grant && (await authorized(grant));
584
596
  },
585
- confirmExecution: async (input) => {
586
- if (toolOwners.has(input.guestId))
587
- return true;
588
- const grant = byGuest.get(input.guestId);
589
- return (!!grant &&
590
- (await confirm(grant, `允许 ${input.appTitle} 运行 ${input.executable}?`, `程序将在服务端运行,可使用当前运行用户的文件与网络权限。\n程序:${input.executablePath}`)));
591
- },
597
+ confirmExecution: async () => true,
592
598
  });
593
599
  const resources = new PanelResourceService({
594
600
  rootDirectory: join(options.dataDir, "panel-app-media"),
@@ -911,6 +917,52 @@ export function createPanelRuntime(options) {
911
917
  });
912
918
  return result && (await authorized(grant));
913
919
  }
920
+ async function pickDirectory(grant) {
921
+ if (!(await authorized(grant)))
922
+ error(410, "面板授权已失效。");
923
+ if ([...grant.confirmations.values()].some((item) => item.picker && item.result === undefined))
924
+ error(409, "请先完成当前目录选择。");
925
+ for (const [id, item] of grant.confirmations) {
926
+ if (item.result !== undefined)
927
+ grant.confirmations.delete(id);
928
+ }
929
+ if (grant.confirmations.size >= 64)
930
+ error(429, "待确认操作过多。");
931
+ const picker = new PanelDirectoryPicker(options.cwd, now);
932
+ const requestId = randomBytes(18).toString("base64url");
933
+ const pending = { picker };
934
+ const allowed = await new Promise((done) => {
935
+ const timer = setTimeout(() => pending.finish(false), directoryPickerLifetime);
936
+ timer.unref();
937
+ pending.finish = (value) => {
938
+ if (pending.result !== undefined)
939
+ return;
940
+ pending.result = value;
941
+ clearTimeout(timer);
942
+ picker.close();
943
+ done(value);
944
+ };
945
+ grant.confirmations.set(requestId, pending);
946
+ emit(grant, "host.confirm", {
947
+ requestId,
948
+ title: options.host === "desktop" ? "选择电脑上的目录" : "选择服务器上的目录",
949
+ body: "仅可选择当前项目内的目录,确认后授予此面板使用。",
950
+ expiresAt: picker.expiresAt,
951
+ directoryPicker: {
952
+ kind: "workspace-directory",
953
+ rootPath: picker.rootPath,
954
+ rootToken: picker.rootToken,
955
+ initialToken: picker.initialToken,
956
+ },
957
+ });
958
+ });
959
+ if (!(await authorized(grant)))
960
+ error(410, "面板授权已失效。");
961
+ if (!allowed)
962
+ return undefined;
963
+ pending.selection.assertCurrent();
964
+ return pending.selection;
965
+ }
914
966
  function taskScope(grant) {
915
967
  return {
916
968
  instanceId: grant.id,
@@ -1166,12 +1218,19 @@ export function createPanelRuntime(options) {
1166
1218
  consentTimeoutMs: 50000,
1167
1219
  },
1168
1220
  }),
1169
- ...(app.permissions.includes("resources")
1221
+ ...(app.permissions.includes("resources") || app.permissions.includes("process")
1170
1222
  ? {
1171
1223
  methodLimits: {
1172
- "resources.recordAudio": { timeoutMs: 30 * 60 * 1000 },
1173
- "resources.recordVideo": { timeoutMs: 30 * 60 * 1000 },
1224
+ ...(app.permissions.includes("resources")
1225
+ ? {
1226
+ "resources.recordAudio": { timeoutMs: 30 * 60 * 1000 },
1227
+ "resources.recordVideo": { timeoutMs: 30 * 60 * 1000 },
1228
+ }
1229
+ : {}),
1174
1230
  ...(app.permissions.includes("process")
1231
+ ? { "filesystem.pickDirectory": { timeoutMs: 6 * 60_000 } }
1232
+ : {}),
1233
+ ...(app.permissions.includes("process") && app.permissions.includes("resources")
1175
1234
  ? {
1176
1235
  "tasks.start": {
1177
1236
  maxParamsBytes: toolJobLimits.maxInputBytes,
@@ -1306,6 +1365,7 @@ export function createPanelRuntime(options) {
1306
1365
  instanceId: id,
1307
1366
  src: ASSETS + asset + "/" + app.entry.split("/").map(encodeURIComponent).join("/"),
1308
1367
  expiresAt: grant.expiresAt,
1368
+ serverTime: now(),
1309
1369
  context,
1310
1370
  limitations: panel.compatibility.reasons,
1311
1371
  };
@@ -1541,9 +1601,8 @@ export function createPanelRuntime(options) {
1541
1601
  if (method.startsWith("agent.task.")) {
1542
1602
  if (!agentTasks)
1543
1603
  error(501, "当前宿主未提供独立面板任务。");
1544
- if (method === "agent.task.start" &&
1545
- !(await confirm(grant, `启动 ${grant.app.title.default} 的 AI 任务?`, String(params?.prompt ?? "").slice(0, 8000))))
1546
- 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.
1547
1606
  return agentTasks.call(taskScope(grant), method, params);
1548
1607
  }
1549
1608
  if (method.startsWith("process.") || method.startsWith("filesystem.")) {
@@ -1583,16 +1642,30 @@ export function createPanelRuntime(options) {
1583
1642
  if (method === "filesystem.restoreDirectory") {
1584
1643
  await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1585
1644
  const saved = directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1586
- if (!options.authorizePanelDirectory &&
1587
- saved !== (await realpath(options.cwd)) &&
1588
- saved !== (await realpath(join(options.cwd, "downloads"))))
1589
- throw new PanelBridgeError("PERMISSION_DENIED", "Saved server directory is unavailable");
1590
- const restored = await processes.grantDirectory(owner, saved);
1645
+ const workspaceSelection = !options.authorizePanelDirectory
1646
+ ? capturePanelWorkspaceDirectory(options.cwd, saved)
1647
+ : undefined;
1648
+ const restored = await processes.grantDirectory(owner, saved, workspaceSelection);
1591
1649
  await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1592
1650
  directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
1651
+ workspaceSelection?.assertCurrent();
1593
1652
  return { ...restored, bookmark: params.bookmark };
1594
1653
  }
1595
- const name = method === "filesystem.pickDirectory" ? "downloads" : params?.name;
1654
+ if (method === "filesystem.pickDirectory") {
1655
+ const selection = await pickDirectory(grant);
1656
+ if (!selection)
1657
+ return { cancelled: true };
1658
+ selection.assertCurrent();
1659
+ const selected = await processes.grantDirectory(owner, selection.path, selection);
1660
+ selection.assertCurrent();
1661
+ if (!(await authorized(grant)))
1662
+ error(410, "面板授权已失效。");
1663
+ selection.assertCurrent();
1664
+ const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
1665
+ processes.directoryPath(owner, selected.handle);
1666
+ return { ...selected, bookmark };
1667
+ }
1668
+ const name = params?.name;
1596
1669
  const directory = name === "project"
1597
1670
  ? options.cwd
1598
1671
  : name === "downloads"
@@ -1604,16 +1677,13 @@ export function createPanelRuntime(options) {
1604
1677
  : undefined;
1605
1678
  if (!directory)
1606
1679
  error(400, "不支持这个服务端目录。");
1607
- if (method === "filesystem.pickDirectory" &&
1608
- !(await confirm(grant, "使用服务端下载目录?", directory)))
1609
- return { cancelled: true };
1610
1680
  await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1611
1681
  if (name !== "project")
1612
1682
  await mkdir(directory, { recursive: true, mode: 0o700 });
1613
1683
  if (!(await authorized(grant)))
1614
1684
  error(410, "面板授权已失效。");
1615
1685
  const selected = await processes.grantDirectory(owner, directory);
1616
- if (method !== "filesystem.pickDirectory" && !["downloads", "project"].includes(String(name)))
1686
+ if (!["downloads", "project"].includes(String(name)))
1617
1687
  return selected;
1618
1688
  const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
1619
1689
  if (!(await authorized(grant)))
@@ -1780,7 +1850,7 @@ export function createPanelRuntime(options) {
1780
1850
  }
1781
1851
  const directoryMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/directory\/([a-f0-9-]{36})$/.exec(url.pathname);
1782
1852
  const resourceMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/resources\/((?:asset|external)-[a-f0-9]{64})$/.exec(url.pathname);
1783
- const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|tool-results))?$/.exec(url.pathname) ??
1853
+ const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|directory-picker|tool-results))?$/.exec(url.pathname) ??
1784
1854
  directoryMatch ??
1785
1855
  resourceMatch;
1786
1856
  const grant = match && grants.get(match[1]);
@@ -1839,7 +1909,7 @@ export function createPanelRuntime(options) {
1839
1909
  if (!(await authorized(grant)))
1840
1910
  error(410, "面板授权已失效。");
1841
1911
  grant.expiresAt = now() + GRANT_TTL;
1842
- json(response, 200, { expiresAt: grant.expiresAt });
1912
+ json(response, 200, { expiresAt: grant.expiresAt, serverTime: now() });
1843
1913
  }
1844
1914
  else if (request.method === "GET" && match[2] === "events") {
1845
1915
  const after = Number(url.searchParams.get("after") ?? 0);
@@ -1848,17 +1918,63 @@ export function createPanelRuntime(options) {
1848
1918
  grant.events = grant.events.filter((entry) => entry.id > after);
1849
1919
  grant.eventBytes = grant.events.reduce((total, entry) => total + Buffer.byteLength(JSON.stringify(entry.payload) ?? "null"), 0);
1850
1920
  const batch = grant.events.slice(0, 128);
1851
- 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
+ });
1926
+ }
1927
+ else if (request.method === "POST" && match[2] === "directory-picker") {
1928
+ if (request.headers.origin !== grant.origin)
1929
+ error(403, "目录选择仅供当前宿主页面使用。");
1930
+ const input = await body(request);
1931
+ if (Object.keys(input).some((key) => !["requestId", "directoryToken"].includes(key)))
1932
+ error(400, "目录选择参数无效。");
1933
+ const pending = typeof input.requestId === "string" && grant.confirmations.get(input.requestId);
1934
+ if (!pending || !pending.picker || pending.result !== undefined)
1935
+ error(409, "目录选择已失效,请重新发起。");
1936
+ const assertPending = async () => {
1937
+ if (!(await authorized(grant)))
1938
+ error(410, "面板授权已失效。");
1939
+ if (pending.result !== undefined || now() >= pending.picker.expiresAt) {
1940
+ pending.finish(false);
1941
+ error(409, "目录选择已失效,请重新发起。");
1942
+ }
1943
+ };
1944
+ await assertPending();
1945
+ const listing = await pending.picker.list(input.directoryToken, assertPending);
1946
+ await assertPending();
1947
+ pending.picker.assertListing(listing);
1948
+ json(response, 200, { requestId: input.requestId, ...listing });
1852
1949
  }
1853
1950
  else if (request.method === "POST" && match[2] === "confirm") {
1854
1951
  const input = await body(request);
1855
1952
  const pending = typeof input.requestId === "string" && grant.confirmations.get(input.requestId);
1856
1953
  if (!pending ||
1857
1954
  typeof input.allowed !== "boolean" ||
1858
- Object.keys(input).some((key) => !["requestId", "allowed"].includes(key)))
1955
+ Object.keys(input).some((key) => !["requestId", "allowed", ...(pending?.picker ? ["directoryToken"] : [])].includes(key)) ||
1956
+ (input.allowed === false && input.directoryToken !== undefined))
1859
1957
  error(400, "找不到这个待确认操作。");
1860
1958
  if (!(await authorized(grant)))
1861
1959
  error(410, "面板授权已失效。");
1960
+ if (pending.picker) {
1961
+ if (request.headers.origin !== grant.origin)
1962
+ error(403, "目录选择仅供当前宿主页面使用。");
1963
+ if (input.allowed) {
1964
+ if (pending.result === undefined) {
1965
+ if (now() >= pending.picker.expiresAt) {
1966
+ pending.finish(false);
1967
+ error(409, "目录选择已失效,请重新发起。");
1968
+ }
1969
+ pending.selection = pending.picker.select(input.directoryToken);
1970
+ pending.directoryToken = input.directoryToken;
1971
+ }
1972
+ else if (pending.result !== true ||
1973
+ input.directoryToken !== pending.directoryToken) {
1974
+ error(409, "目录选择已失效,请重新发起。");
1975
+ }
1976
+ }
1977
+ }
1862
1978
  pending.finish(input.allowed);
1863
1979
  json(response, 200, { accepted: true, allowed: pending.result });
1864
1980
  }
@@ -1984,12 +2100,14 @@ export function createPanelRuntime(options) {
1984
2100
  // including when the public site is behind HTTPS termination.
1985
2101
  const source = publicPathPrefix + ASSETS + grant.asset + "/";
1986
2102
  if (extname(path).toLowerCase() === ".html") {
1987
- bytes = Buffer.from(injectBridge(bytes.toString("utf8"), source));
1988
2103
  const resourceOrigin = grant.origin;
2104
+ bytes = Buffer.from(resolveMetaPolicySelf(injectBridge(bytes.toString("utf8"), source), resourceOrigin));
1989
2105
  const assetSource = resourceOrigin + source;
1990
2106
  // Panels export their own generated files (for example JSON backups).
1991
2107
  // Download permission does not grant origin access, network fetches or popups.
1992
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.
1993
2111
  response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
1994
2112
  assetSource +
1995
2113
  " 'wasm-unsafe-eval'; style-src " +
@@ -2002,7 +2120,7 @@ export function createPanelRuntime(options) {
2002
2120
  assetSource +
2003
2121
  " data:; connect-src " +
2004
2122
  assetSource +
2005
- "; 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 " +
2006
2124
  resourceOrigin);
2007
2125
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
2008
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. */