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.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +332 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5940 -297
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/ui-server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
|
85
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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,
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
208
|
-
|
|
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
|
|
447
|
+
if (!body || Object.keys(body).sort().join(",") !== "instanceId,mode,nonce,ok,port,proof,proofVersion,version")
|
|
212
448
|
return false;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
}
|