sealkeep 0.9.0 → 0.11.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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +252 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
  16. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
  17. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  18. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  20. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  21. package/dist/site/index.html +1808 -1904
  22. package/dist/site/llms.txt +67 -0
  23. package/dist/site/trust/architecture-data-flow.html +53 -0
  24. package/dist/site/trust/audit-roadmap.html +37 -0
  25. package/dist/site/trust/deployment-responsibility.html +11 -0
  26. package/dist/site/trust/dpa-sample.html +30 -0
  27. package/dist/site/trust/release-provenance.html +21 -0
  28. package/dist/site/trust/subprocessors.html +15 -0
  29. package/dist/site/trust/threat-model.html +34 -0
  30. package/dist/site/trust/toms.html +41 -0
  31. package/dist/site/trust-document.css +32 -0
  32. package/dist/site/trust.html +73 -0
  33. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  34. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  35. package/dist/site/visual/index.html +18 -0
  36. package/dist/site.zip +0 -0
  37. package/dist/src/activity.d.ts +9 -0
  38. package/dist/src/activity.js +90 -1
  39. package/dist/src/adapters.d.ts +175 -5
  40. package/dist/src/adapters.js +961 -73
  41. package/dist/src/agent-context.d.ts +135 -0
  42. package/dist/src/agent-context.js +1059 -0
  43. package/dist/src/archive-copies.d.ts +47 -0
  44. package/dist/src/archive-copies.js +179 -0
  45. package/dist/src/audit.d.ts +1 -1
  46. package/dist/src/audit.js +29 -4
  47. package/dist/src/autopilot.d.ts +52 -7
  48. package/dist/src/autopilot.js +143 -25
  49. package/dist/src/background-bandwidth.d.ts +46 -0
  50. package/dist/src/background-bandwidth.js +301 -0
  51. package/dist/src/background-cpu.d.ts +82 -0
  52. package/dist/src/background-cpu.js +212 -0
  53. package/dist/src/background-worker-error.d.ts +12 -0
  54. package/dist/src/background-worker-error.js +18 -0
  55. package/dist/src/branding.d.ts +24 -1
  56. package/dist/src/branding.js +31 -1
  57. package/dist/src/bridge.d.ts +233 -0
  58. package/dist/src/bridge.js +604 -0
  59. package/dist/src/byte-stream.d.ts +91 -0
  60. package/dist/src/byte-stream.js +385 -0
  61. package/dist/src/chunk-store.d.ts +41 -8
  62. package/dist/src/chunk-store.js +161 -65
  63. package/dist/src/cli.js +1698 -163
  64. package/dist/src/cloud.d.ts +841 -31
  65. package/dist/src/cloud.js +3196 -277
  66. package/dist/src/context-background.d.ts +37 -0
  67. package/dist/src/context-background.js +309 -0
  68. package/dist/src/context-drain-child.d.ts +1 -0
  69. package/dist/src/context-drain-child.js +98 -0
  70. package/dist/src/context-reader.d.ts +118 -0
  71. package/dist/src/context-reader.js +447 -0
  72. package/dist/src/control-plane/auth.d.ts +32 -4
  73. package/dist/src/control-plane/auth.js +85 -24
  74. package/dist/src/control-plane/server.js +19 -6
  75. package/dist/src/control-plane.d.ts +17 -1
  76. package/dist/src/control-plane.js +32 -6
  77. package/dist/src/crypto.d.ts +1 -1
  78. package/dist/src/crypto.js +5 -5
  79. package/dist/src/daemon-lease.d.ts +70 -0
  80. package/dist/src/daemon-lease.js +420 -0
  81. package/dist/src/daemon.d.ts +94 -1
  82. package/dist/src/daemon.js +1082 -105
  83. package/dist/src/darwin-service-policy.d.ts +41 -0
  84. package/dist/src/darwin-service-policy.js +60 -0
  85. package/dist/src/dashboard-cli.js +15 -15
  86. package/dist/src/device-authorization.d.ts +37 -0
  87. package/dist/src/device-authorization.js +199 -0
  88. package/dist/src/device-enrollment.d.ts +91 -0
  89. package/dist/src/device-enrollment.js +349 -0
  90. package/dist/src/disk.d.ts +17 -12
  91. package/dist/src/disk.js +43 -17
  92. package/dist/src/doctor.d.ts +35 -1
  93. package/dist/src/doctor.js +332 -41
  94. package/dist/src/durable-ticket-lock.d.ts +24 -0
  95. package/dist/src/durable-ticket-lock.js +232 -0
  96. package/dist/src/enroll.d.ts +1 -1
  97. package/dist/src/enroll.js +13 -7
  98. package/dist/src/env.d.ts +10 -1
  99. package/dist/src/env.js +11 -3
  100. package/dist/src/errors.d.ts +8 -8
  101. package/dist/src/errors.js +6 -6
  102. package/dist/src/flush.d.ts +12 -0
  103. package/dist/src/flush.js +37 -0
  104. package/dist/src/heartbeat.d.ts +86 -12
  105. package/dist/src/heartbeat.js +415 -29
  106. package/dist/src/index-background-watchdog.d.ts +1 -0
  107. package/dist/src/index-background-watchdog.js +94 -0
  108. package/dist/src/index-background-work.d.ts +21 -0
  109. package/dist/src/index-background-work.js +25 -0
  110. package/dist/src/index-background.d.ts +64 -0
  111. package/dist/src/index-background.js +394 -0
  112. package/dist/src/index-build-child.d.ts +1 -0
  113. package/dist/src/index-build-child.js +109 -0
  114. package/dist/src/index-manifest.d.ts +52 -0
  115. package/dist/src/index-manifest.js +444 -0
  116. package/dist/src/index-publication-proof.d.ts +84 -0
  117. package/dist/src/index-publication-proof.js +380 -0
  118. package/dist/src/index-publication-state.d.ts +149 -0
  119. package/dist/src/index-publication-state.js +696 -0
  120. package/dist/src/index-publication-verifier.d.ts +89 -0
  121. package/dist/src/index-publication-verifier.js +341 -0
  122. package/dist/src/index-publish.d.ts +62 -0
  123. package/dist/src/index-publish.js +540 -0
  124. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  125. package/dist/src/index-scratch-cleanup.js +166 -0
  126. package/dist/src/index-segment-types.d.ts +132 -0
  127. package/dist/src/index-segment-types.js +21 -0
  128. package/dist/src/index-segments.d.ts +9 -0
  129. package/dist/src/index-segments.js +516 -0
  130. package/dist/src/index-store.d.ts +123 -0
  131. package/dist/src/index-store.js +495 -0
  132. package/dist/src/index-sync.d.ts +91 -15
  133. package/dist/src/index-sync.js +286 -53
  134. package/dist/src/index-upgrade-publication.d.ts +30 -0
  135. package/dist/src/index-upgrade-publication.js +179 -0
  136. package/dist/src/integration-manager.d.ts +32 -0
  137. package/dist/src/integration-manager.js +394 -0
  138. package/dist/src/leakscan.js +1 -1
  139. package/dist/src/local-api.d.ts +56 -15
  140. package/dist/src/local-api.js +4979 -541
  141. package/dist/src/machine-settings.d.ts +51 -0
  142. package/dist/src/machine-settings.js +166 -0
  143. package/dist/src/managed-chunks.d.ts +5 -2
  144. package/dist/src/managed-chunks.js +14 -14
  145. package/dist/src/mcp-install.d.ts +11 -9
  146. package/dist/src/mcp-install.js +73 -28
  147. package/dist/src/mcp-workspace.d.ts +18 -0
  148. package/dist/src/mcp-workspace.js +50 -0
  149. package/dist/src/mcp.js +294 -25
  150. package/dist/src/migrate.js +27 -21
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5940 -297
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +226 -30
  249. package/dist/src/vault.js +1776 -192
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,7 +1,9 @@
1
- import { spawn } from "node:child_process";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
- import { createLocalApiServer, localApiToken } from "./local-api.js";
5
+ import { promisify } from "node:util";
6
+ import { createLocalApiServer, LOCAL_UI_PROOF_VERSION, localApiToken, localUiBootstrapProof, localUiProof, localUiProofKey, localUiProofKeyPath, } from "./local-api.js";
5
7
  /**
6
8
  * The GUI, reachable without typing a command.
7
9
  *
@@ -11,12 +13,39 @@ import { createLocalApiServer, localApiToken } from "./local-api.js";
11
13
  * pointed at it, authenticated, without a person copying a token by hand.
12
14
  *
13
15
  * This module is the plumbing for that and nothing else. It starts (or finds)
14
- * the loopback server, builds the URL that carries the token, and launches a
15
- * browser. Every line printed to a terminal lives in cli.ts, so this stays
16
+ * the loopback server, mints a short-lived one-use browser ticket, and launches
17
+ * a browser. Every line printed to a terminal lives in cli.ts, so this stays
16
18
  * usable by any surface that is not a terminal — the native shell in §5 v2
17
19
  * being the obvious one.
18
20
  */
19
21
  const LOOPBACK_HOST = "127.0.0.1";
22
+ const execFileAsync = promisify(execFile);
23
+ /**
24
+ * Process-generation helpers are part of listener ownership, so they must be
25
+ * both exact and bounded. On a heavily loaded Mac, `ps` can inherit the
26
+ * service's scheduling class and otherwise wait indefinitely before emitting
27
+ * one line. A timeout is uncertainty, never evidence that an owner is dead.
28
+ */
29
+ const PROCESS_IDENTITY_COMMAND_TIMEOUT_MS = 750;
30
+ const PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS = 1_000;
31
+ const processIdentityCommandOptions = {
32
+ windowsHide: true,
33
+ timeout: PROCESS_IDENTITY_COMMAND_TIMEOUT_MS,
34
+ killSignal: "SIGKILL",
35
+ maxBuffer: 4 * 1024,
36
+ };
37
+ /**
38
+ * `taskpolicy -B` is PID-only on macOS. Its supported command form is `-a`,
39
+ * which gives the child application scheduling/resource policy instead of
40
+ * inheriting an old service definition's background policy. Keep both paths
41
+ * argument-vector based: no shell parses a PID or command.
42
+ */
43
+ export function uiPsIdentityCommand(pid, targetPlatform = process.platform) {
44
+ const args = ["-p", String(pid), "-o", "lstart="];
45
+ return targetPlatform === "darwin"
46
+ ? { file: "/usr/sbin/taskpolicy", args: ["-a", "/bin/ps", ...args] }
47
+ : { file: "ps", args };
48
+ }
20
49
  /**
21
50
  * The name to show a person, as opposed to the address to bind.
22
51
  *
@@ -43,8 +72,6 @@ export const DISPLAY_HOST = "localhost";
43
72
  * window on an odd port beats a correct error.
44
73
  */
45
74
  export const DEFAULT_UI_PORT = 7477;
46
- /** The hostnames this service answers to, mirrored by ALLOWED_HOSTS in local-api.ts. */
47
- export const UI_HOSTNAMES = ["localhost", "127.0.0.1", "vault.line", "vault.localhost"];
48
75
  /** Loopback round trips are sub-millisecond; anything slower is a listener that has stopped answering. */
49
76
  const PROBE_TIMEOUT_MS = 1_500;
50
77
  /** How long to wait for an opener to fail before assuming the window is on its way. See openInBrowser. */
@@ -65,40 +92,253 @@ export async function readUiRecord(dataDir) {
65
92
  const port = Number(saved?.port);
66
93
  if (!Number.isInteger(port) || port < 1 || port > 65_535)
67
94
  return null;
68
- return { port, pid: Number(saved?.pid) || 0, startedAt: String(saved?.startedAt ?? "") };
95
+ const owner = saved?.owner === "autopilot" || saved?.owner === "interactive" ? saved.owner : undefined;
96
+ const requestedPort = Number(saved?.requestedPort);
97
+ const instanceId = typeof saved?.instanceId === "string" && /^[A-Za-z0-9_-]{16,128}$/.test(saved.instanceId)
98
+ ? saved.instanceId
99
+ : undefined;
100
+ const processIdentity = typeof saved?.processIdentity === "string" && saved.processIdentity.length > 0 && saved.processIdentity.length <= 512
101
+ ? saved.processIdentity
102
+ : undefined;
103
+ return {
104
+ port,
105
+ pid: Number(saved?.pid) || 0,
106
+ startedAt: String(saved?.startedAt ?? ""),
107
+ ...(owner ? { owner } : {}),
108
+ ...(Number.isInteger(requestedPort) && requestedPort >= 0 && requestedPort <= 65_535 ? { requestedPort } : {}),
109
+ ...(instanceId ? { instanceId } : {}),
110
+ ...(processIdentity ? { processIdentity } : {}),
111
+ };
69
112
  }
70
113
  catch {
71
114
  return null;
72
115
  }
73
116
  }
74
117
  /**
75
- * Written in place rather than through a temp file and a rename: a half-written
76
- * record fails to parse, and failing to parse already means "start a fresh
77
- * server", which is the safe answer. Atomicity would buy nothing.
118
+ * Atomically published: a reader sees either the preceding complete owner or
119
+ * the next complete owner, never a truncated file that could start a second
120
+ * mutating API server.
78
121
  */
79
122
  export async function writeUiRecord(dataDir, record) {
80
123
  await mkdir(dataDir, { recursive: true, mode: 0o700 });
81
- await writeFile(uiRecordPath(dataDir), JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
124
+ const path = uiRecordPath(dataDir);
125
+ const temporary = `${path}.${randomUUID()}.tmp`;
126
+ try {
127
+ await writeFile(temporary, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
128
+ await rename(temporary, path);
129
+ }
130
+ catch (error) {
131
+ await rm(temporary, { force: true }).catch(() => undefined);
132
+ throw error;
133
+ }
82
134
  }
83
135
  /**
84
- * Removes the record only when it still names this server's port. Two servers
85
- * can exist at once (a second one starts whenever the first is unreachable),
86
- * and the first to shut down must not delete the survivor's announcement.
136
+ * Removes the record only when it still names this exact listener generation.
137
+ * A closing old process must not delete a replacement's announcement.
87
138
  */
88
- export async function clearUiRecord(dataDir, port) {
89
- if (port !== undefined) {
139
+ export async function clearUiRecord(dataDir, port, instanceId) {
140
+ if (port !== undefined || instanceId !== undefined) {
90
141
  const recorded = await readUiRecord(dataDir);
91
- if (recorded && recorded.port !== port)
142
+ if (!recorded || (port !== undefined && recorded.port !== port) || (instanceId !== undefined && recorded.instanceId !== instanceId))
92
143
  return;
93
144
  }
94
145
  await rm(uiRecordPath(dataDir), { force: true });
95
146
  }
147
+ const uiLeaseDir = (dataDir, lane) => join(dataDir, `ui-${lane}-leases`);
148
+ const pause = (milliseconds) => new Promise((resolve) => {
149
+ setTimeout(resolve, milliseconds);
150
+ });
151
+ async function readLeaseCandidate(path) {
152
+ try {
153
+ const value = JSON.parse(await readFile(path, "utf8"));
154
+ if (typeof value.instanceId !== "string" || !Number.isInteger(value.pid) || !Number.isFinite(value.createdAtMs)
155
+ || typeof value.processIdentity !== "string" || value.processIdentity.length < 1 || value.processIdentity.length > 512)
156
+ return null;
157
+ return value;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * Kernel-backed process generation, not merely a reusable PID.
165
+ *
166
+ * Listener leases survive SIGKILL by design. A later, unrelated process can be
167
+ * assigned the same PID, so `kill(pid, 0)` alone turns the dead lease back into
168
+ * a convincing owner forever. Linux exposes an exact boot id + start tick;
169
+ * macOS/BSD expose the process start instant through ps; Windows exposes the
170
+ * same creation instant through PowerShell. Failure is explicit: starting a
171
+ * second stateful API is less safe than refusing when ownership cannot be
172
+ * proved.
173
+ */
174
+ function processLiveness(pid) {
175
+ if (!Number.isInteger(pid) || pid <= 0)
176
+ return "dead";
177
+ try {
178
+ process.kill(pid, 0);
179
+ return "alive";
180
+ }
181
+ catch (error) {
182
+ return error.code === "ESRCH" ? "dead" : "unknown";
183
+ }
184
+ }
185
+ export const uiProcessIdentity = async (pid) => {
186
+ const before = processLiveness(pid);
187
+ if (before === "dead")
188
+ return { state: "dead" };
189
+ if (before === "unknown")
190
+ return { state: "unknown" };
191
+ try {
192
+ if (process.platform === "linux") {
193
+ const [raw, bootId] = await Promise.all([
194
+ readFile(`/proc/${pid}/stat`, "utf8"),
195
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
196
+ ]);
197
+ const afterName = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
198
+ const startTicks = afterName[19]; // proc(5) field 22; this slice starts at field 3.
199
+ if (startTicks && /^\d+$/.test(startTicks) && bootId.trim()) {
200
+ return { state: "identified", identity: `linux:${bootId.trim()}:${startTicks}` };
201
+ }
202
+ }
203
+ else if (process.platform === "win32") {
204
+ const command = `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;
205
+ const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], processIdentityCommandOptions);
206
+ const started = stdout.trim();
207
+ if (/^\d+$/.test(started))
208
+ return { state: "identified", identity: `windows:${started}` };
209
+ }
210
+ else {
211
+ const command = uiPsIdentityCommand(pid);
212
+ const { stdout } = await execFileAsync(command.file, command.args, processIdentityCommandOptions);
213
+ const started = stdout.trim();
214
+ if (started)
215
+ return { state: "identified", identity: `ps:${started}` };
216
+ }
217
+ }
218
+ catch { }
219
+ // The process can exit between the liveness and identity queries. Only that
220
+ // confirmed ESRCH makes the lease stale; permission errors and transient
221
+ // kernel/tool failures remain unknown and therefore fail closed.
222
+ return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
223
+ };
224
+ /**
225
+ * Test seams are bounded too: production must remain safe if an OS helper,
226
+ * filesystem lookup, or injected platform adapter never settles. Unknown is
227
+ * deliberately fail-closed; only an explicit dead result permits reclaiming
228
+ * another generation's lease.
229
+ */
230
+ async function boundedProcessIdentity(lookup, pid) {
231
+ let timeout;
232
+ const deadline = new Promise((resolve) => {
233
+ timeout = setTimeout(() => resolve({ state: "unknown" }), PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS);
234
+ });
235
+ try {
236
+ const result = await Promise.race([
237
+ Promise.resolve().then(() => lookup(pid)),
238
+ deadline,
239
+ ]);
240
+ if (result.state === "dead" || result.state === "unknown")
241
+ return result;
242
+ if (result.state === "identified" && typeof result.identity === "string"
243
+ && result.identity.length > 0 && result.identity.length <= 512)
244
+ return result;
245
+ return { state: "unknown" };
246
+ }
247
+ catch {
248
+ return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
249
+ }
250
+ finally {
251
+ if (timeout)
252
+ clearTimeout(timeout);
253
+ }
254
+ }
255
+ async function candidateState(value, lookup = uiProcessIdentity) {
256
+ const current = await boundedProcessIdentity(lookup, value.pid);
257
+ if (current.state === "dead")
258
+ return "stale";
259
+ if (current.state === "unknown")
260
+ return "unknown";
261
+ return current.identity === value.processIdentity ? "current" : "stale";
262
+ }
263
+ async function ownsLease(dataDir, lane, expected, lookup = uiProcessIdentity) {
264
+ const value = await readLeaseCandidate(join(uiLeaseDir(dataDir, lane), `${expected.instanceId}.json`));
265
+ return Boolean(value && value.pid === expected.pid && value.processIdentity === expected.processIdentity
266
+ && value.instanceId === expected.instanceId && await candidateState(value, lookup) === "current");
267
+ }
268
+ /**
269
+ * Cross-process election for the one stateful local API allowed per vault.
270
+ *
271
+ * Each contender owns a uniquely named file, so cleanup never unlinks another
272
+ * generation. A short contention window lets simultaneous service/UI starts
273
+ * see the same candidate set; the earliest creation wins and remains present
274
+ * for the listener lifetime. Later contenders can only borrow its listener.
275
+ */
276
+ async function acquireUiLease(dataDir, lane, lookup = uiProcessIdentity) {
277
+ const directory = uiLeaseDir(dataDir, lane);
278
+ await mkdir(directory, { recursive: true, mode: 0o700 });
279
+ const instanceId = randomBytes(24).toString("base64url");
280
+ const path = join(directory, `${instanceId}.json`);
281
+ // One election may inspect several lease files belonging to the same
282
+ // process (including its own freshly published candidate). Resolve each PID
283
+ // generation once so one `ps` delay cannot be multiplied by the file count.
284
+ const generations = new Map();
285
+ const lookupOnce = (pid) => {
286
+ let pending = generations.get(pid);
287
+ if (!pending) {
288
+ pending = boundedProcessIdentity(lookup, pid);
289
+ generations.set(pid, pending);
290
+ }
291
+ return pending;
292
+ };
293
+ const current = await lookupOnce(process.pid);
294
+ if (current.state !== "identified")
295
+ throw new Error("Sealkeep could not verify this process generation, so it refused to start a second stateful local API");
296
+ const identity = current.identity;
297
+ const candidate = { instanceId, pid: process.pid, processIdentity: identity, createdAtMs: Date.now() };
298
+ await writeFile(path, JSON.stringify(candidate) + "\n", { flag: "wx", mode: 0o600 });
299
+ const release = () => rm(path, { force: true });
300
+ await pause(80);
301
+ const entries = await readdir(directory).catch(() => []);
302
+ const active = [];
303
+ let uncertain = false;
304
+ for (const name of entries) {
305
+ if (!name.endsWith(".json"))
306
+ continue;
307
+ const candidatePath = join(directory, name);
308
+ const value = await readLeaseCandidate(candidatePath);
309
+ if (!value) {
310
+ await rm(candidatePath, { force: true }).catch(() => undefined);
311
+ continue;
312
+ }
313
+ const state = await candidateState(value, lookupOnce);
314
+ if (state === "stale") {
315
+ await rm(candidatePath, { force: true }).catch(() => undefined);
316
+ continue;
317
+ }
318
+ if (state === "unknown") {
319
+ uncertain = true;
320
+ continue;
321
+ }
322
+ active.push(value);
323
+ }
324
+ if (uncertain) {
325
+ await release();
326
+ return null;
327
+ }
328
+ active.sort((left, right) => left.createdAtMs - right.createdAtMs || left.instanceId.localeCompare(right.instanceId));
329
+ if (active[0]?.instanceId !== instanceId) {
330
+ await release();
331
+ return null;
332
+ }
333
+ return { instanceId, processIdentity: identity, path, release };
334
+ }
96
335
  /** Where a person goes. Bound on loopback, named so it can be read and typed. */
97
336
  export const uiOrigin = (port, host = DISPLAY_HOST) => `http://${host}:${port}`;
98
337
  /** Where this process talks to itself. Always the address, never a name that has to resolve. */
99
338
  export const uiProbeOrigin = (port) => `http://${LOOPBACK_HOST}:${port}`;
100
339
  /**
101
- * How the browser gets authenticated: the token rides in the URL fragment.
340
+ * How the browser gets authenticated: a short-lived one-use ticket rides in
341
+ * the URL fragment; the permanent bearer never enters opener argv.
102
342
  *
103
343
  * A fragment is the only part of a URL a browser never transmits. It is not in
104
344
  * the request line, so it cannot reach an access log, a proxy, or a Referer
@@ -111,14 +351,10 @@ export const uiProbeOrigin = (port) => `http://${LOOPBACK_HOST}:${port}`;
111
351
  * is the terminal step this whole command exists to remove, and it puts the
112
352
  * secret in scrollback, in `script` logs, and in any pasted transcript.
113
353
  *
114
- * What this does cost: the URL is an argv of the opener process, so it is
115
- * visible to `ps` for the few milliseconds that process lives. That is
116
- * accepted rather than overlooked — the token grants nothing that a local
117
- * process running as this user does not already have, since it can read
118
- * <data-dir>/local-api-token directly. Scrollback is the leak that outlives
119
- * the session; argv is not.
354
+ * The ticket is visible briefly in opener argv, but it expires after seconds,
355
+ * is redeemable once, and can only be minted through the owner-only proof key.
120
356
  */
121
- export const uiUrl = (port, token) => `${uiOrigin(port)}/#token=${encodeURIComponent(token)}`;
357
+ export const uiUrl = (port, ticket) => `${uiOrigin(port)}/#ticket=${encodeURIComponent(ticket)}`;
122
358
  /**
123
359
  * Which command opens a URL here.
124
360
  *
@@ -191,35 +427,65 @@ export async function openInBrowser(url, options = {}) {
191
427
  child.unref();
192
428
  });
193
429
  }
194
- /**
195
- * Is a Sealkeep local API still listening on `port`, and is it *this* vault's?
196
- *
197
- * Two questions, because a recorded port answers neither. A port is reused by
198
- * whatever process grabs it next, so `/health` first establishes that the
199
- * listener is one of ours at all. Then a `/v1/status` call carrying this
200
- * vault's token establishes that it reads the same token file — that is, that
201
- * it serves this data dir and not a second vault's. 401 is the only answer
202
- * that means "not this vault": every other status got past the token check,
203
- * including the 404 a data dir with no vault yet would return.
204
- */
205
- export async function servesThisVault(port, token, fetchImpl = fetch) {
430
+ function exactJson(response) {
431
+ return response.status === 200 && /^application\/json(?:;|$)/i.test(response.headers.get("content-type") ?? "");
432
+ }
433
+ function secretEqual(left, right) {
434
+ const a = Buffer.from(left);
435
+ const b = Buffer.from(right);
436
+ return a.length === b.length && timingSafeEqual(a, b);
437
+ }
438
+ export async function servesThisVault(port, credentials, fetchImpl = fetch) {
206
439
  try {
207
- const health = await fetchImpl(`${uiProbeOrigin(port)}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
208
- if (!health.ok)
440
+ const nonce = randomBytes(32).toString("base64url");
441
+ const health = await fetchImpl(`${uiProbeOrigin(port)}/health?nonce=${encodeURIComponent(nonce)}`, {
442
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
443
+ });
444
+ if (!exactJson(health))
209
445
  return false;
210
446
  const body = await health.json().catch(() => null);
211
- if (body?.mode !== "local-api")
447
+ if (!body || Object.keys(body).sort().join(",") !== "instanceId,mode,nonce,ok,port,proof,proofVersion,version")
212
448
  return false;
213
- const authorized = await fetchImpl(`${uiProbeOrigin(port)}/v1/status`, {
214
- headers: { authorization: `Bearer ${token}` },
215
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
216
- });
217
- return authorized.status !== 401;
449
+ if (body.ok !== true || body.mode !== "local-api" || body.version !== 1 || body.proofVersion !== LOCAL_UI_PROOF_VERSION
450
+ || body.nonce !== nonce || body.port !== port || body.instanceId !== credentials.instanceId || typeof body.proof !== "string")
451
+ return false;
452
+ return secretEqual(body.proof, localUiProof(credentials.proofKey, nonce, port, credentials.instanceId));
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ }
458
+ /** Compatibility-only proof that a pre-ticket listener is actually present. */
459
+ async function servesLegacyLocalApi(port, fetchImpl = fetch) {
460
+ try {
461
+ const response = await fetchImpl(`${uiProbeOrigin(port)}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
462
+ if (!exactJson(response))
463
+ return false;
464
+ const body = await response.json().catch(() => null);
465
+ return Boolean(body && body.ok === true && body.mode === "local-api" && body.version === 1);
218
466
  }
219
467
  catch {
220
468
  return false;
221
469
  }
222
470
  }
471
+ async function issueBootstrapTicket(port, credentials, fetchImpl = fetch) {
472
+ const nonce = randomBytes(32).toString("base64url");
473
+ const proof = localUiBootstrapProof(credentials.proofKey, nonce, port, credentials.instanceId);
474
+ const response = await fetchImpl(`${uiProbeOrigin(port)}/v1/auth/bootstrap-ticket`, {
475
+ method: "POST",
476
+ headers: { "content-type": "application/json", accept: "application/json" },
477
+ body: JSON.stringify({ nonce, instanceId: credentials.instanceId, proof }),
478
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
479
+ });
480
+ if (!exactJson(response))
481
+ throw new Error("The authenticated local UI bootstrap request was refused");
482
+ const body = await response.json().catch(() => null);
483
+ if (!body || body.instanceId !== credentials.instanceId || typeof body.ticket !== "string"
484
+ || !/^[A-Za-z0-9_-]{43}$/.test(body.ticket) || typeof body.expiresAt !== "string") {
485
+ throw new Error("The local UI returned an invalid bootstrap ticket");
486
+ }
487
+ return body.ticket;
488
+ }
223
489
  /**
224
490
  * Starts the local UI, or points at the one already running for this vault.
225
491
  *
@@ -230,7 +496,7 @@ export async function servesThisVault(port, token, fetchImpl = fetch) {
230
496
  * what someone typing the command twice actually meant.
231
497
  */
232
498
  export async function startUi(dataDir, options = {}) {
233
- const token = await localApiToken(dataDir);
499
+ const [token, proofKey] = await Promise.all([localApiToken(dataDir), localUiProofKey(dataDir)]);
234
500
  const open = options.open ?? ((url) => openInBrowser(url));
235
501
  const wantsBrowser = options.openBrowser ?? true;
236
502
  const noBrowser = { opened: false, command: null, declined: true };
@@ -250,44 +516,418 @@ export async function startUi(dataDir, options = {}) {
250
516
  return { opened: false, command: null, problem: error instanceof Error ? error.message : "could not open a browser" };
251
517
  }
252
518
  };
253
- const recorded = (options.reuse ?? true) ? await readUiRecord(dataDir) : null;
254
- if (recorded && await servesThisVault(recorded.port, token, options.fetchImpl ?? fetch)) {
255
- const url = uiUrl(recorded.port, token);
519
+ const fetchImpl = options.fetchImpl ?? fetch;
520
+ const adopt = async () => {
521
+ const recorded = await readUiRecord(dataDir);
522
+ if (!recorded?.instanceId || !await servesThisVault(recorded.port, { proofKey, instanceId: recorded.instanceId }, fetchImpl))
523
+ return null;
524
+ const ticket = await issueBootstrapTicket(recorded.port, { proofKey, instanceId: recorded.instanceId }, fetchImpl);
525
+ const url = uiUrl(recorded.port, ticket);
256
526
  return {
257
- port: recorded.port, origin: uiOrigin(recorded.port), address: LOOPBACK_HOST, url, reused: true, running: recorded,
527
+ port: recorded.port,
528
+ origin: uiOrigin(recorded.port),
529
+ address: LOOPBACK_HOST,
530
+ url,
531
+ reused: true,
532
+ running: recorded,
533
+ instanceId: recorded.instanceId,
534
+ isListening: () => false,
258
535
  browser: await launch(url),
259
- // Another process owns that server; stopping it from here would close a
260
- // window somebody else's terminal claims to be holding open.
261
- close: async () => { }
536
+ close: async () => { },
262
537
  };
538
+ };
539
+ // `reuse:false` used to create a second stateful API process. That is never
540
+ // safe: upload/reclaim journals and in-memory transfer ownership are per
541
+ // vault. Keep accepting the option for source compatibility, but ownership
542
+ // is now unconditionally singleton.
543
+ const already = await adopt();
544
+ if (already)
545
+ return already;
546
+ // A pre-ticket release may still own the vault after an in-place upgrade.
547
+ // It cannot prove its generation, so adopting it would be unsafe; starting a
548
+ // second stateful API would be worse. Make the owner restart explicit and
549
+ // leave its record untouched instead of racing the same transfer journal.
550
+ const legacy = await readUiRecord(dataDir);
551
+ if (legacy && !legacy.instanceId && await servesLegacyLocalApi(legacy.port, fetchImpl)) {
552
+ throw new Error("An older Sealkeep local UI still owns this vault; stop or restart it before opening the upgraded UI");
553
+ }
554
+ let lease = null;
555
+ for (let attempt = 0; attempt < 3 && !lease; attempt += 1) {
556
+ lease = await acquireUiLease(dataDir, "listener", options.processIdentityLookup);
557
+ if (lease)
558
+ break;
559
+ const deadline = Date.now() + PROBE_TIMEOUT_MS * 2;
560
+ while (Date.now() < deadline) {
561
+ const borrowed = await adopt();
562
+ if (borrowed)
563
+ return borrowed;
564
+ await pause(25);
565
+ }
263
566
  }
264
- const server = createLocalApiServer(dataDir, token);
567
+ if (!lease)
568
+ throw new Error("Another Sealkeep process owns this vault's local API but has not become ready yet");
569
+ const instanceId = lease.instanceId;
570
+ const server = createLocalApiServer(dataDir, token, {
571
+ listenerInstanceId: instanceId,
572
+ onRecoveryPhraseAvailable: options.onRecoveryPhraseAvailable,
573
+ });
265
574
  // The stable port is a preference, not a requirement. Something else holding
266
575
  // 7477 is not this user's problem to solve before they can see their vault,
267
576
  // so a busy port falls back to whatever is free rather than failing.
268
577
  const wanted = options.port ?? DEFAULT_UI_PORT;
269
- await new Promise((ready, broken) => {
270
- const settle = () => { server.off("error", retry); ready(); };
271
- const retry = (error) => {
272
- if (wanted === 0 || (error.code !== "EADDRINUSE" && error.code !== "EACCES")) {
273
- broken(error);
274
- return;
275
- }
276
- server.once("error", broken);
277
- server.listen(0, LOOPBACK_HOST, settle);
278
- };
279
- server.once("error", retry);
280
- server.listen(wanted, LOOPBACK_HOST, settle);
281
- });
578
+ try {
579
+ await new Promise((ready, broken) => {
580
+ const settle = () => { server.off("error", retry); ready(); };
581
+ const retry = (error) => {
582
+ if (wanted === 0 || (error.code !== "EADDRINUSE" && error.code !== "EACCES")) {
583
+ broken(error);
584
+ return;
585
+ }
586
+ server.once("error", broken);
587
+ server.listen(0, LOOPBACK_HOST, settle);
588
+ };
589
+ server.once("error", retry);
590
+ server.listen(wanted, LOOPBACK_HOST, settle);
591
+ });
592
+ }
593
+ catch (error) {
594
+ await lease.release().catch(() => undefined);
595
+ throw error;
596
+ }
282
597
  const { port } = server.address();
283
- await writeUiRecord(dataDir, { port, pid: process.pid, startedAt: new Date().toISOString() });
284
- const url = uiUrl(port, token);
598
+ try {
599
+ await writeUiRecord(dataDir, {
600
+ port,
601
+ pid: process.pid,
602
+ startedAt: new Date().toISOString(),
603
+ owner: options.owner ?? "interactive",
604
+ requestedPort: wanted,
605
+ instanceId,
606
+ processIdentity: lease.processIdentity,
607
+ });
608
+ }
609
+ catch (error) {
610
+ await new Promise((done) => server.close(() => done()));
611
+ await lease.release().catch(() => undefined);
612
+ throw error;
613
+ }
614
+ let ticket;
615
+ try {
616
+ ticket = await issueBootstrapTicket(port, { proofKey, instanceId }, fetchImpl);
617
+ }
618
+ catch (error) {
619
+ await new Promise((done) => server.close(() => done()));
620
+ await clearUiRecord(dataDir, port, instanceId);
621
+ await lease.release().catch(() => undefined);
622
+ throw error;
623
+ }
624
+ const url = uiUrl(port, ticket);
285
625
  return {
286
- port, origin: uiOrigin(port), address: server.address().address, url, reused: false, running: null,
626
+ port, origin: uiOrigin(port), address: server.address().address, url, reused: false, running: null, instanceId,
627
+ isListening: () => server.listening,
287
628
  browser: await launch(url),
288
629
  close: async () => {
289
- await clearUiRecord(dataDir, port);
290
630
  await new Promise((done) => server.close(() => done()));
631
+ // The lease remains held while the exact generation record is removed,
632
+ // so no replacement can be published between compare and unlink.
633
+ await clearUiRecord(dataDir, port, instanceId);
634
+ await lease.release();
635
+ }
636
+ };
637
+ }
638
+ /**
639
+ * Runtime evidence for the background service's UI lane.
640
+ *
641
+ * This is deliberately state, not preference: `daemon --serve-ui` in the
642
+ * native service definition is the preference. This owner-only record merely
643
+ * says which process is supervising the listener now, where it actually
644
+ * landed after a port collision, and why it is retrying if it could not bind.
645
+ */
646
+ export const uiSupervisorPath = (dataDir) => join(dataDir, "ui-supervisor.json");
647
+ // Same-process close/start overlap is common in tests and service kickstarts.
648
+ // Track the exact generation, never the data directory, so an old close cannot
649
+ // hide a replacement and entries are removed as soon as that close publishes.
650
+ const closingSupervisorInstances = new Set();
651
+ async function readUiSupervisorRecord(dataDir) {
652
+ try {
653
+ const value = JSON.parse(await readFile(uiSupervisorPath(dataDir), "utf8"));
654
+ if (value.version !== 1 || !Number.isInteger(value.pid) || value.pid <= 0)
655
+ return null;
656
+ if (typeof value.instanceId !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value.instanceId))
657
+ return null;
658
+ if (typeof value.processIdentity !== "string" || value.processIdentity.length < 1 || value.processIdentity.length > 512)
659
+ return null;
660
+ if (value.state !== "starting" && value.state !== "running" && value.state !== "retrying" && value.state !== "off")
661
+ return null;
662
+ const requestedPort = Number(value.requestedPort);
663
+ if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
664
+ return null;
665
+ return value;
666
+ }
667
+ catch {
668
+ return null;
669
+ }
670
+ }
671
+ async function writeUiSupervisorRecord(dataDir, value) {
672
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
673
+ const path = uiSupervisorPath(dataDir);
674
+ const temporary = `${path}.${randomUUID()}.tmp`;
675
+ try {
676
+ await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
677
+ await rename(temporary, path);
678
+ }
679
+ catch (error) {
680
+ await rm(temporary, { force: true }).catch(() => undefined);
681
+ throw error;
682
+ }
683
+ }
684
+ /** Live truth for CLI/setup status; stale pid/record files never count as available. */
685
+ export async function uiRuntimeStatus(dataDir, options = {}) {
686
+ const [supervisor, listener, proofKey] = await Promise.all([
687
+ readUiSupervisorRecord(dataDir),
688
+ readUiRecord(dataDir),
689
+ readFile(localUiProofKeyPath(dataDir), "utf8").then((raw) => raw.trim()).catch(() => ""),
690
+ ]);
691
+ const listenerAvailable = Boolean(listener?.instanceId && proofKey
692
+ && await servesThisVault(listener.port, { proofKey, instanceId: listener.instanceId }, options.fetchImpl ?? fetch));
693
+ const supervisorLeaseIsCurrent = supervisor ? await ownsLease(dataDir, "supervisor", {
694
+ instanceId: supervisor.instanceId,
695
+ pid: supervisor.pid,
696
+ processIdentity: supervisor.processIdentity,
697
+ createdAtMs: 0,
698
+ }, options.processIdentityLookup) : false;
699
+ const supervisorOwnsCurrent = Boolean(supervisor && supervisor.state !== "off"
700
+ && !closingSupervisorInstances.has(supervisor.instanceId) && supervisorLeaseIsCurrent
701
+ && (supervisor.state !== "running" || (listenerAvailable && listener && supervisor.port === listener.port
702
+ && supervisor.listenerPid === listener.pid && supervisor.listenerInstanceId === listener.instanceId)));
703
+ const supervised = supervisorOwnsCurrent;
704
+ const available = listenerAvailable;
705
+ const requestedPort = (supervised ? supervisor?.requestedPort : undefined) ?? listener?.requestedPort ?? DEFAULT_UI_PORT;
706
+ return {
707
+ supervised,
708
+ available,
709
+ state: available ? "running" : supervised ? "retrying" : "off",
710
+ requestedPort,
711
+ port: available ? listener.port : null,
712
+ origin: available ? uiProbeOrigin(listener.port) : null,
713
+ address: available ? LOOPBACK_HOST : null,
714
+ fallback: Boolean(available && requestedPort !== 0 && listener.port !== requestedPort),
715
+ supervisorPid: supervised ? supervisor.pid : null,
716
+ listenerPid: available ? listener.pid : null,
717
+ listenerOwner: available ? listener.owner ?? "interactive" : null,
718
+ checkedAt: supervised ? supervisor.checkedAt : null,
719
+ nextRetryAt: supervised && !available ? supervisor.nextRetryAt : null,
720
+ lastError: supervised && !available ? supervisor.lastError : null,
721
+ };
722
+ }
723
+ /**
724
+ * Keeps one authenticated loopback UI available beside Autopilot.
725
+ *
726
+ * It opens no window. A live UI already serving this vault is reused and
727
+ * watched; if that owner later exits, the next bounded check replaces it. An
728
+ * unrelated process on 7477 is never adopted: startUi falls back to a free
729
+ * loopback port and the exact port is written to both runtime records.
730
+ */
731
+ export async function startUiSupervisor(dataDir, options = {}) {
732
+ const requestedPort = options.port ?? DEFAULT_UI_PORT;
733
+ if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535) {
734
+ throw new RangeError("UI port must be an integer from 0 to 65535");
735
+ }
736
+ const retryMs = Math.max(50, options.retryMs ?? 5_000);
737
+ const start = options.start ?? startUi;
738
+ const fetchImpl = options.fetchImpl ?? fetch;
739
+ let supervisorLease = null;
740
+ for (let attempt = 0; attempt < 4 && !supervisorLease; attempt += 1) {
741
+ supervisorLease = await acquireUiLease(dataDir, "supervisor", options.processIdentityLookup);
742
+ if (supervisorLease)
743
+ break;
744
+ const current = await uiRuntimeStatus(dataDir, {
745
+ fetchImpl: options.fetchImpl,
746
+ processIdentityLookup: options.processIdentityLookup,
747
+ });
748
+ if (current.supervised)
749
+ break;
750
+ await pause(50);
751
+ }
752
+ if (!supervisorLease) {
753
+ // A concurrent service owner won the per-vault election. This handle is a
754
+ // borrower: it can report that owner's truth but may never stop it.
755
+ const deadline = Date.now() + PROBE_TIMEOUT_MS * 2;
756
+ while (Date.now() < deadline) {
757
+ const status = await uiRuntimeStatus(dataDir, {
758
+ fetchImpl: options.fetchImpl,
759
+ processIdentityLookup: options.processIdentityLookup,
760
+ });
761
+ if (status.supervised)
762
+ break;
763
+ await pause(25);
764
+ }
765
+ return {
766
+ owned: false,
767
+ refresh: () => uiRuntimeStatus(dataDir, {
768
+ fetchImpl: options.fetchImpl,
769
+ processIdentityLookup: options.processIdentityLookup,
770
+ }),
771
+ status: () => uiRuntimeStatus(dataDir, {
772
+ fetchImpl: options.fetchImpl,
773
+ processIdentityLookup: options.processIdentityLookup,
774
+ }),
775
+ close: async () => { },
776
+ };
777
+ }
778
+ const supervisorInstanceId = supervisorLease.instanceId;
779
+ const startedAt = new Date().toISOString();
780
+ let handle = null;
781
+ let closed = false;
782
+ let timer = null;
783
+ let inFlight = null;
784
+ const publish = async (patch) => {
785
+ if (closed)
786
+ return;
787
+ const prior = await readUiSupervisorRecord(dataDir);
788
+ const now = new Date().toISOString();
789
+ await writeUiSupervisorRecord(dataDir, {
790
+ version: 1,
791
+ instanceId: supervisorInstanceId,
792
+ pid: process.pid,
793
+ processIdentity: supervisorLease.processIdentity,
794
+ state: "starting",
795
+ requestedPort,
796
+ port: null,
797
+ listenerPid: null,
798
+ listenerOwner: null,
799
+ listenerInstanceId: null,
800
+ ownsListener: false,
801
+ startedAt: prior?.instanceId === supervisorInstanceId ? prior.startedAt : startedAt,
802
+ checkedAt: now,
803
+ nextRetryAt: null,
804
+ lastError: null,
805
+ ...patch,
806
+ });
807
+ };
808
+ const ensure = async () => {
809
+ if (closed)
810
+ return;
811
+ if (handle) {
812
+ // A listener created by this supervisor has a stronger, synchronous
813
+ // source of truth than an HTTP round trip through its own event loop.
814
+ // On a large vault a dashboard request may occupy that loop for longer
815
+ // than PROBE_TIMEOUT_MS even though the socket and server are healthy.
816
+ // Closing on that timeout used to interrupt the browser's one-use ticket
817
+ // redemption, producing a visible connected/disconnected loop. Only a
818
+ // borrowed listener needs the authenticated cross-process probe.
819
+ const listenerResponding = !handle.reused && handle.isListening()
820
+ ? true
821
+ : await readFile(localUiProofKeyPath(dataDir), "utf8")
822
+ .then((raw) => raw.trim())
823
+ .then((proofKey) => Boolean(proofKey) && servesThisVault(handle.port, { proofKey, instanceId: handle.instanceId }, fetchImpl))
824
+ .catch(() => false);
825
+ if (listenerResponding) {
826
+ await publish({
827
+ state: "running",
828
+ port: handle.port,
829
+ listenerPid: handle.reused ? handle.running?.pid ?? null : process.pid,
830
+ listenerOwner: handle.reused ? handle.running?.owner ?? "interactive" : "autopilot",
831
+ listenerInstanceId: handle.instanceId,
832
+ ownsListener: !handle.reused,
833
+ });
834
+ return;
835
+ }
836
+ if (!handle.reused)
837
+ await handle.close().catch(() => undefined);
838
+ handle = null;
839
+ }
840
+ await publish({ state: "starting" });
841
+ try {
842
+ const next = await start(dataDir, {
843
+ port: requestedPort,
844
+ openBrowser: false,
845
+ reuse: true,
846
+ owner: "autopilot",
847
+ fetchImpl,
848
+ onRecoveryPhraseAvailable: options.onRecoveryPhraseAvailable,
849
+ processIdentityLookup: options.processIdentityLookup,
850
+ });
851
+ if (closed) {
852
+ if (!next.reused)
853
+ await next.close().catch(() => undefined);
854
+ return;
855
+ }
856
+ handle = next;
857
+ await publish({
858
+ state: "running",
859
+ port: next.port,
860
+ listenerPid: next.reused ? next.running?.pid ?? null : process.pid,
861
+ listenerOwner: next.reused ? next.running?.owner ?? "interactive" : "autopilot",
862
+ listenerInstanceId: next.instanceId,
863
+ ownsListener: !next.reused,
864
+ });
865
+ }
866
+ catch (error) {
867
+ const nextRetryAt = new Date(Date.now() + retryMs).toISOString();
868
+ await publish({
869
+ state: "retrying",
870
+ nextRetryAt,
871
+ lastError: error instanceof Error ? error.message.split("\n")[0] : "local UI could not start",
872
+ }).catch(() => undefined);
291
873
  }
292
874
  };
875
+ const refresh = async () => {
876
+ if (!inFlight) {
877
+ const current = ensure();
878
+ inFlight = current;
879
+ // Observe both outcomes on this housekeeping branch. `finally()` would
880
+ // create a second rejecting promise and turn a handled write error into
881
+ // an unhandled rejection in the long-running service.
882
+ void current.then(() => { if (inFlight === current)
883
+ inFlight = null; }, () => { if (inFlight === current)
884
+ inFlight = null; });
885
+ }
886
+ const current = inFlight;
887
+ if (current)
888
+ await current;
889
+ return uiRuntimeStatus(dataDir, { fetchImpl, processIdentityLookup: options.processIdentityLookup });
890
+ };
891
+ await refresh();
892
+ timer = setInterval(() => { void refresh().catch(() => undefined); }, retryMs);
893
+ timer.unref?.();
894
+ let closeInFlight = null;
895
+ return {
896
+ owned: true,
897
+ refresh,
898
+ status: () => uiRuntimeStatus(dataDir, { fetchImpl, processIdentityLookup: options.processIdentityLookup }),
899
+ close: () => {
900
+ if (closeInFlight)
901
+ return closeInFlight;
902
+ closeInFlight = (async () => {
903
+ closed = true;
904
+ closingSupervisorInstances.add(supervisorInstanceId);
905
+ if (timer)
906
+ clearInterval(timer);
907
+ timer = null;
908
+ await inFlight?.catch(() => undefined);
909
+ const owned = handle && !handle.reused ? handle : null;
910
+ handle = null;
911
+ await owned?.close().catch(() => undefined);
912
+ const current = await readUiSupervisorRecord(dataDir);
913
+ if (current?.instanceId === supervisorInstanceId) {
914
+ await writeUiSupervisorRecord(dataDir, {
915
+ ...current,
916
+ state: "off",
917
+ port: null,
918
+ listenerPid: null,
919
+ listenerOwner: null,
920
+ listenerInstanceId: null,
921
+ ownsListener: false,
922
+ checkedAt: new Date().toISOString(),
923
+ nextRetryAt: null,
924
+ lastError: null,
925
+ }).catch(() => undefined);
926
+ }
927
+ await supervisorLease.release().catch(() => undefined);
928
+ closingSupervisorInstances.delete(supervisorInstanceId);
929
+ })();
930
+ return closeInFlight;
931
+ },
932
+ };
293
933
  }