sealkeep 0.9.0 → 0.11.0
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 +223 -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.js +4 -4
- 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 +316 -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 +5897 -290
- 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/recipients.d.ts +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
|
@@ -35,8 +35,8 @@ const enrollSchema = z.object({
|
|
|
35
35
|
label: z.string().min(1).max(120),
|
|
36
36
|
publicKey: z.string().min(40).max(100)
|
|
37
37
|
}).strict();
|
|
38
|
-
function json(response, status, value) {
|
|
39
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
38
|
+
function json(response, status, value, headers = {}) {
|
|
39
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers });
|
|
40
40
|
response.end(JSON.stringify(value));
|
|
41
41
|
}
|
|
42
42
|
async function readBody(request) {
|
|
@@ -84,11 +84,24 @@ export function createControlPlaneServer(options = {}) {
|
|
|
84
84
|
let status = 500;
|
|
85
85
|
let deviceId;
|
|
86
86
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// The remote address is not caller-controlled. Using the presented
|
|
88
|
+
// device header here let an unauthenticated caller rotate fake ids and
|
|
89
|
+
// mint an unlimited number of fresh buckets before signature checking.
|
|
90
|
+
const limitKey = request.socket.remoteAddress ?? "anonymous";
|
|
91
|
+
const limited = limiter.consume(limitKey, now());
|
|
92
|
+
if (!limited.allowed) {
|
|
89
93
|
status = 429;
|
|
90
94
|
store.audit({ action: `${method} ${url.pathname}`, outcome: "denied", detail: { reason: "rate_limited" } });
|
|
91
|
-
|
|
95
|
+
const retryAfterSeconds = Math.max(1, Math.ceil(limited.retryAfterMs / 1_000));
|
|
96
|
+
return json(response, 429, { error: {
|
|
97
|
+
code: "rate_limited", message: "Too many requests",
|
|
98
|
+
retryAfterMs: limited.retryAfterMs, retryAt: new Date(now() + limited.retryAfterMs).toISOString(),
|
|
99
|
+
} }, {
|
|
100
|
+
"retry-after": String(retryAfterSeconds),
|
|
101
|
+
"ratelimit-limit": String(limiter.capacity),
|
|
102
|
+
"ratelimit-remaining": String(limited.remaining),
|
|
103
|
+
"ratelimit-reset": String(Math.ceil(limited.resetAt / 1_000)),
|
|
104
|
+
});
|
|
92
105
|
}
|
|
93
106
|
if (method === "GET" && url.pathname === "/health") {
|
|
94
107
|
status = 200;
|
|
@@ -179,7 +192,7 @@ export function createControlPlaneServer(options = {}) {
|
|
|
179
192
|
return json(response, 402, { error: { code: "quota_exceeded", message: "This account has no remaining quota" } });
|
|
180
193
|
}
|
|
181
194
|
// The object key is allocated here. A client cannot choose where its bytes land.
|
|
182
|
-
const objectKey = `${config.prefix.replace(/^\/+|\/+$/g, "")}/${input.archiveId}.
|
|
195
|
+
const objectKey = `${config.prefix.replace(/^\/+|\/+$/g, "")}/${input.archiveId}.skarchive`;
|
|
183
196
|
const issuedAt = now();
|
|
184
197
|
const lease = store.addLease({
|
|
185
198
|
id: randomUUID(), accountId: device.accountId, deviceId: device.id, archiveId: input.archiveId, storageConfigId: config.id,
|
|
@@ -53,7 +53,23 @@ export declare function leaseExpired(lease: UploadLease, now?: number): boolean;
|
|
|
53
53
|
* replayed lease is refused as expired rather than as an unsigned lease.
|
|
54
54
|
*/
|
|
55
55
|
export declare function assertLeaseUsable(lease: UploadLease, now?: number): void;
|
|
56
|
-
|
|
56
|
+
export type DaemonSignerGrant = {
|
|
57
|
+
release(): void;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Grants provider-signing capability to one running daemon instance.
|
|
61
|
+
*
|
|
62
|
+
* The grant is process-local and lifetime-scoped: it does not modify the
|
|
63
|
+
* environment or make a separate CLI process upload-capable. Reference
|
|
64
|
+
* counting matters for embedded/test processes which legitimately host more
|
|
65
|
+
* than one daemon for different vaults.
|
|
66
|
+
*/
|
|
67
|
+
export declare function acquireDaemonSignerGrant(): DaemonSignerGrant;
|
|
68
|
+
/**
|
|
69
|
+
* Explicit operator enablement, or a capability held by a live daemon.
|
|
70
|
+
* Supplying an environment is a hermetic check: callers which deliberately
|
|
71
|
+
* pass `{}` must not inherit capability from another daemon in this process.
|
|
72
|
+
*/
|
|
57
73
|
export declare function signerEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
58
74
|
/**
|
|
59
75
|
* The contract a real S3/R2/GCS client will implement. Credentials belong to the
|
|
@@ -18,7 +18,7 @@ class PlannedProvider {
|
|
|
18
18
|
// Deliberately non-functional until real credential-backed signers are configured.
|
|
19
19
|
return {
|
|
20
20
|
archiveId, provider: this.provider,
|
|
21
|
-
objectKey: `${config.prefix.replace(/\/$/, "")}/${archiveId}.
|
|
21
|
+
objectKey: `${config.prefix.replace(/\/$/, "")}/${archiveId}.skarchive`,
|
|
22
22
|
expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
|
|
23
23
|
method: this.method,
|
|
24
24
|
uploadUrl: `vaultline+pending://${this.provider}/${config.bucket}/${archiveId}`,
|
|
@@ -43,18 +43,44 @@ export function assertLeaseUsable(lease, now = Date.now()) {
|
|
|
43
43
|
if (!lease.uploadable || lease.status !== "active")
|
|
44
44
|
fail("signer_not_configured", "This lease cannot upload: no credential-backed signer is configured", { provider: lease.provider, archiveId: lease.archiveId });
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
let daemonSignerGrants = 0;
|
|
47
|
+
/**
|
|
48
|
+
* Grants provider-signing capability to one running daemon instance.
|
|
49
|
+
*
|
|
50
|
+
* The grant is process-local and lifetime-scoped: it does not modify the
|
|
51
|
+
* environment or make a separate CLI process upload-capable. Reference
|
|
52
|
+
* counting matters for embedded/test processes which legitimately host more
|
|
53
|
+
* than one daemon for different vaults.
|
|
54
|
+
*/
|
|
55
|
+
export function acquireDaemonSignerGrant() {
|
|
56
|
+
daemonSignerGrants += 1;
|
|
57
|
+
let held = true;
|
|
58
|
+
return {
|
|
59
|
+
release() {
|
|
60
|
+
if (!held)
|
|
61
|
+
return;
|
|
62
|
+
held = false;
|
|
63
|
+
daemonSignerGrants = Math.max(0, daemonSignerGrants - 1);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Explicit operator enablement, or a capability held by a live daemon.
|
|
69
|
+
* Supplying an environment is a hermetic check: callers which deliberately
|
|
70
|
+
* pass `{}` must not inherit capability from another daemon in this process.
|
|
71
|
+
*/
|
|
72
|
+
export function signerEnabled(env) {
|
|
73
|
+
return envVar("ENABLE_SIGNER", env ?? process.env) === "1"
|
|
74
|
+
|| (env === undefined && daemonSignerGrants > 0);
|
|
49
75
|
}
|
|
50
76
|
const uploadClients = new Map();
|
|
51
77
|
/** Registration is refused unless the operator explicitly enabled signer work. */
|
|
52
|
-
export function registerUploadClient(client, env
|
|
78
|
+
export function registerUploadClient(client, env) {
|
|
53
79
|
if (!signerEnabled(env))
|
|
54
80
|
fail("signer_not_configured", "Set SEALKEEP_ENABLE_SIGNER=1 to register a provider upload client", { provider: client.kind });
|
|
55
81
|
uploadClients.set(client.kind, client);
|
|
56
82
|
}
|
|
57
|
-
export function uploadClientFor(kind, env
|
|
83
|
+
export function uploadClientFor(kind, env) {
|
|
58
84
|
const client = signerEnabled(env) ? uploadClients.get(kind) : undefined;
|
|
59
85
|
return client ?? fail("signer_not_configured", `No upload client is available for ${kind}. Sealkeep cannot upload until a reviewed signer with isolated credentials exists.`, { provider: kind, signerEnabled: signerEnabled(env) });
|
|
60
86
|
}
|
package/dist/src/crypto.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export declare function encryptLegacyArchive(plaintext: Buffer, phrase: string):
|
|
|
43
43
|
ciphertext: string;
|
|
44
44
|
};
|
|
45
45
|
};
|
|
46
|
-
/** @deprecated Format v1 reader. New archives use `
|
|
46
|
+
/** @deprecated Format v1 reader. New archives use `sealkeep-crypto`. */
|
|
47
47
|
export declare function decryptLegacyArchive(ciphertext: Buffer, envelope: {
|
|
48
48
|
nonce: string;
|
|
49
49
|
authTag: string;
|
package/dist/src/crypto.js
CHANGED
|
@@ -96,14 +96,14 @@ export function encryptLegacyArchive(plaintext, phrase) {
|
|
|
96
96
|
const archiveKey = randomBytes(KEY_BYTES);
|
|
97
97
|
const nonce = randomBytes(NONCE_BYTES);
|
|
98
98
|
const cipher = createCipheriv("aes-256-gcm", archiveKey, nonce, { authTagLength: TAG_BYTES });
|
|
99
|
-
cipher.setAAD(Buffer.from("
|
|
99
|
+
cipher.setAAD(Buffer.from("sealkeep-archive:v1"));
|
|
100
100
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
101
101
|
const authTag = cipher.getAuthTag();
|
|
102
102
|
const salt = randomBytes(16);
|
|
103
103
|
const wrapNonce = randomBytes(NONCE_BYTES);
|
|
104
104
|
const wrappingKey = recoveryKey(phrase, salt);
|
|
105
105
|
const wrapper = createCipheriv("aes-256-gcm", wrappingKey, wrapNonce, { authTagLength: TAG_BYTES });
|
|
106
|
-
wrapper.setAAD(Buffer.from("
|
|
106
|
+
wrapper.setAAD(Buffer.from("sealkeep-wrapped-key:v1"));
|
|
107
107
|
const wrapped = Buffer.concat([wrapper.update(archiveKey), wrapper.final()]);
|
|
108
108
|
return {
|
|
109
109
|
ciphertext,
|
|
@@ -118,15 +118,15 @@ export function encryptLegacyArchive(plaintext, phrase) {
|
|
|
118
118
|
}
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
|
-
/** @deprecated Format v1 reader. New archives use `
|
|
121
|
+
/** @deprecated Format v1 reader. New archives use `sealkeep-crypto`. */
|
|
122
122
|
export function decryptLegacyArchive(ciphertext, envelope, phrase) {
|
|
123
123
|
const wrappingKey = recoveryKey(phrase, Buffer.from(envelope.wrappedKey.salt, "base64url"));
|
|
124
124
|
const unwrap = createDecipheriv("aes-256-gcm", wrappingKey, Buffer.from(envelope.wrappedKey.nonce, "base64url"), { authTagLength: TAG_BYTES });
|
|
125
|
-
unwrap.setAAD(Buffer.from("
|
|
125
|
+
unwrap.setAAD(Buffer.from("sealkeep-wrapped-key:v1"));
|
|
126
126
|
unwrap.setAuthTag(Buffer.from(envelope.wrappedKey.authTag, "base64url"));
|
|
127
127
|
const archiveKey = Buffer.concat([unwrap.update(Buffer.from(envelope.wrappedKey.ciphertext, "base64url")), unwrap.final()]);
|
|
128
128
|
const decipher = createDecipheriv("aes-256-gcm", archiveKey, Buffer.from(envelope.nonce, "base64url"), { authTagLength: TAG_BYTES });
|
|
129
|
-
decipher.setAAD(Buffer.from("
|
|
129
|
+
decipher.setAAD(Buffer.from("sealkeep-archive:v1"));
|
|
130
130
|
decipher.setAuthTag(Buffer.from(envelope.authTag, "base64url"));
|
|
131
131
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
132
132
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type DaemonProcessIdentityLookupResult = {
|
|
2
|
+
state: "identified";
|
|
3
|
+
identity: string;
|
|
4
|
+
} | {
|
|
5
|
+
state: "dead";
|
|
6
|
+
} | {
|
|
7
|
+
state: "unknown";
|
|
8
|
+
};
|
|
9
|
+
export type DaemonProcessIdentityLookup = (pid: number) => Promise<DaemonProcessIdentityLookupResult>;
|
|
10
|
+
export type DaemonWorkerLease = {
|
|
11
|
+
instanceId: string;
|
|
12
|
+
pid: number;
|
|
13
|
+
processIdentity: string;
|
|
14
|
+
path: string;
|
|
15
|
+
/** Proves this exact process generation still owns the published lease. */
|
|
16
|
+
assertOwned(): Promise<void>;
|
|
17
|
+
release(): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A process-generation election, separate from the UI listener election.
|
|
21
|
+
*
|
|
22
|
+
* The listener may be replaced or borrowed during a package-manager/service
|
|
23
|
+
* reload. Queue, index, upload, retention and team reconciliation are a single
|
|
24
|
+
* stateful unit and must stay owned by the old daemon until all of its lanes
|
|
25
|
+
* have quiesced. Keeping this election in the vault makes manager crashes and
|
|
26
|
+
* PID reuse safe too; no particular service manager is trusted as the lock.
|
|
27
|
+
*/
|
|
28
|
+
export declare const daemonWorkerLeaseDir: (dataDir: string) => string;
|
|
29
|
+
/**
|
|
30
|
+
* The installed service may itself still carry an older background scheduling
|
|
31
|
+
* class while it reconciles its launchd definition. On macOS, execute the
|
|
32
|
+
* tiny `ps` probe at the default task policy so that migration can make
|
|
33
|
+
* progress under load. This is an argv-only wrapper: no shell or interpolation.
|
|
34
|
+
*/
|
|
35
|
+
export declare function daemonPsIdentityCommand(pid: number, platform?: NodeJS.Platform): {
|
|
36
|
+
file: string;
|
|
37
|
+
args: string[];
|
|
38
|
+
};
|
|
39
|
+
export declare const daemonProcessIdentity: DaemonProcessIdentityLookup;
|
|
40
|
+
/** Legacy ps generations did not record locale/timezone. A changed rendering
|
|
41
|
+
* cannot prove PID reuse across an upgrade; a still-live legacy owner remains
|
|
42
|
+
* uncertain until it exits instead of being mistaken for a stale process. */
|
|
43
|
+
export declare function classifyDaemonProcessGeneration(owner: DaemonProcessIdentityLookupResult, expectedIdentity: string): "current" | "stale" | "unknown";
|
|
44
|
+
/** Publish before a worker receives work or SIGSTOP. The immutable candidate
|
|
45
|
+
* outlives its daemon, so a replacement cannot overlap an orphan's last writes.
|
|
46
|
+
* It is not released on parent shutdown until the exact child is proved gone. */
|
|
47
|
+
export declare function registerDaemonIndexWorker(dataDir: string, pid: number, processIdentity: string, options?: {
|
|
48
|
+
identityLookup?: DaemonProcessIdentityLookup;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
scratchGeneration: string;
|
|
51
|
+
retireAfterExit(): Promise<void>;
|
|
52
|
+
}>;
|
|
53
|
+
/** Context also writes durable per-session state; a replacement daemon must
|
|
54
|
+
* wait for its exact old generation, even while the orphan watchdog cancels it. */
|
|
55
|
+
export declare function registerDaemonContextWorker(dataDir: string, pid: number, processIdentity: string): Promise<{
|
|
56
|
+
retireAfterExit(): Promise<void>;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Elects the sole complete daemon worker for this vault.
|
|
60
|
+
*
|
|
61
|
+
* `null` means another identified generation owns the vault, or an existing
|
|
62
|
+
* record could not safely be classified. Uncertainty fails closed: preserving
|
|
63
|
+
* availability never justifies two retention/index writers.
|
|
64
|
+
*/
|
|
65
|
+
export declare function acquireDaemonWorkerLease(dataDir: string, options?: {
|
|
66
|
+
identityLookup?: DaemonProcessIdentityLookup;
|
|
67
|
+
electionWindowMs?: number;
|
|
68
|
+
/** Test seam; production uses a one-second fail-closed ceiling. */
|
|
69
|
+
identityLookupTimeoutMs?: number;
|
|
70
|
+
}): Promise<DaemonWorkerLease | null>;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { cleanupInterruptedIndexScratch } from "./index-scratch-cleanup.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const ELECTION_WINDOW_MS = 80;
|
|
9
|
+
// Process generation is part of the safety boundary, but it must not become an
|
|
10
|
+
// availability boundary. On a heavily loaded Mac, `ps` can inherit the
|
|
11
|
+
// daemon's scheduling class and otherwise wait indefinitely before emitting
|
|
12
|
+
// its one line. A timeout is uncertainty, never proof that a lease is stale.
|
|
13
|
+
// A bound on the identity probe, not a budget for it. POSIX answers from
|
|
14
|
+
// procfs or a `ps` call in milliseconds; Windows has no such file and must
|
|
15
|
+
// start PowerShell, which on a cold, loaded machine takes seconds — at 750 ms
|
|
16
|
+
// every probe there timed out, and a timed-out probe reads as "this process
|
|
17
|
+
// cannot be verified", which refuses queue locks, scratch reservations and
|
|
18
|
+
// workers outright (about 150 failures on a Windows CI runner).
|
|
19
|
+
const PROCESS_IDENTITY_COMMAND_TIMEOUT_MS = process.platform === "win32" ? 10_000 : 750;
|
|
20
|
+
const PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS = 1_000;
|
|
21
|
+
const processIdentityCommandOptions = {
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
timeout: PROCESS_IDENTITY_COMMAND_TIMEOUT_MS,
|
|
24
|
+
killSignal: "SIGKILL",
|
|
25
|
+
maxBuffer: 4 * 1024,
|
|
26
|
+
// ps lstart is otherwise localized and timezone-dependent: two observers
|
|
27
|
+
// could call the same live generation stale, or fail to arm its watchdog.
|
|
28
|
+
env: identityCommandEnvironment(),
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The environment the identity command runs with.
|
|
32
|
+
*
|
|
33
|
+
* The allowlist exists for POSIX, where `ps lstart` prints a localized,
|
|
34
|
+
* timezone-dependent date: two observers reading different locales could call
|
|
35
|
+
* the same live generation stale. Windows reads an integer tick count, which
|
|
36
|
+
* no locale can change, and powershell.exe will not start under a pruned
|
|
37
|
+
* environment — measured on a CI runner, it hung until the probe's timeout
|
|
38
|
+
* (10 s) with a short list and answered in 0.6 s with the inherited one, and
|
|
39
|
+
* a probe that cannot answer refuses queue locks, scratch reservations and
|
|
40
|
+
* workers. So Windows inherits the environment and pins only the locale.
|
|
41
|
+
*/
|
|
42
|
+
function identityCommandEnvironment() {
|
|
43
|
+
const deterministic = { LC_ALL: "C", LANG: "C", TZ: "UTC" };
|
|
44
|
+
if (process.platform === "win32")
|
|
45
|
+
return { ...process.env, ...deterministic };
|
|
46
|
+
return {
|
|
47
|
+
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR,
|
|
48
|
+
TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR,
|
|
49
|
+
...deterministic,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let currentDaemonProcessIdentity;
|
|
53
|
+
/**
|
|
54
|
+
* A process-generation election, separate from the UI listener election.
|
|
55
|
+
*
|
|
56
|
+
* The listener may be replaced or borrowed during a package-manager/service
|
|
57
|
+
* reload. Queue, index, upload, retention and team reconciliation are a single
|
|
58
|
+
* stateful unit and must stay owned by the old daemon until all of its lanes
|
|
59
|
+
* have quiesced. Keeping this election in the vault makes manager crashes and
|
|
60
|
+
* PID reuse safe too; no particular service manager is trusted as the lock.
|
|
61
|
+
*/
|
|
62
|
+
export const daemonWorkerLeaseDir = (dataDir) => join(dataDir, "daemon-worker-leases");
|
|
63
|
+
const pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
64
|
+
function processLiveness(pid) {
|
|
65
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
66
|
+
return "dead";
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return "alive";
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return error.code === "ESRCH" ? "dead" : "unknown";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The installed service may itself still carry an older background scheduling
|
|
77
|
+
* class while it reconciles its launchd definition. On macOS, execute the
|
|
78
|
+
* tiny `ps` probe at the default task policy so that migration can make
|
|
79
|
+
* progress under load. This is an argv-only wrapper: no shell or interpolation.
|
|
80
|
+
*/
|
|
81
|
+
export function daemonPsIdentityCommand(pid, platform = process.platform) {
|
|
82
|
+
const args = ["-p", String(pid), "-o", "lstart="];
|
|
83
|
+
return platform === "darwin"
|
|
84
|
+
? { file: "/usr/sbin/taskpolicy", args: ["-a", "/bin/ps", ...args] }
|
|
85
|
+
: { file: "ps", args };
|
|
86
|
+
}
|
|
87
|
+
/** A PID plus this kernel-backed generation is not reusable by a later process. */
|
|
88
|
+
async function uncachedDaemonProcessIdentity(pid) {
|
|
89
|
+
const before = processLiveness(pid);
|
|
90
|
+
if (before === "dead")
|
|
91
|
+
return { state: "dead" };
|
|
92
|
+
if (before === "unknown")
|
|
93
|
+
return { state: "unknown" };
|
|
94
|
+
try {
|
|
95
|
+
if (process.platform === "linux") {
|
|
96
|
+
const [raw, bootId] = await Promise.all([
|
|
97
|
+
readFile(`/proc/${pid}/stat`, "utf8"),
|
|
98
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
99
|
+
]);
|
|
100
|
+
const fieldsAfterName = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
101
|
+
const startTicks = fieldsAfterName[19]; // proc(5) field 22; this slice begins at field 3.
|
|
102
|
+
if (startTicks && /^\d+$/.test(startTicks) && bootId.trim()) {
|
|
103
|
+
return { state: "identified", identity: `linux:${bootId.trim()}:${startTicks}` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (process.platform === "win32") {
|
|
107
|
+
const command = `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;
|
|
108
|
+
const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], processIdentityCommandOptions);
|
|
109
|
+
const started = stdout.trim();
|
|
110
|
+
if (/^\d+$/.test(started))
|
|
111
|
+
return { state: "identified", identity: `windows:${started}` };
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const command = daemonPsIdentityCommand(pid);
|
|
115
|
+
const { stdout } = await execFileAsync(command.file, command.args, processIdentityCommandOptions);
|
|
116
|
+
const started = stdout.trim();
|
|
117
|
+
if (started)
|
|
118
|
+
return { state: "identified", identity: `ps-utc:${started}` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
|
|
123
|
+
}
|
|
124
|
+
export const daemonProcessIdentity = async (pid) => {
|
|
125
|
+
if (pid !== process.pid)
|
|
126
|
+
return uncachedDaemonProcessIdentity(pid);
|
|
127
|
+
if (!currentDaemonProcessIdentity) {
|
|
128
|
+
const lookup = uncachedDaemonProcessIdentity(pid);
|
|
129
|
+
currentDaemonProcessIdentity = lookup;
|
|
130
|
+
void lookup.then((result) => {
|
|
131
|
+
// This process's exact kernel generation cannot change while it remains
|
|
132
|
+
// alive, so an identified result is safe to retain. A transient failure
|
|
133
|
+
// must not poison every future election in this long-lived process.
|
|
134
|
+
if (result.state !== "identified" && currentDaemonProcessIdentity === lookup) {
|
|
135
|
+
currentDaemonProcessIdentity = undefined;
|
|
136
|
+
}
|
|
137
|
+
}, () => {
|
|
138
|
+
if (currentDaemonProcessIdentity === lookup)
|
|
139
|
+
currentDaemonProcessIdentity = undefined;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return currentDaemonProcessIdentity;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Bounds both production and injected generation lookups. Only an explicit
|
|
146
|
+
* `dead` result permits reclaiming another candidate; timeout, exceptions and
|
|
147
|
+
* malformed adapter results all remain unknown and therefore fail closed.
|
|
148
|
+
*/
|
|
149
|
+
async function boundedProcessIdentity(lookup, pid, timeoutMs) {
|
|
150
|
+
let timeout;
|
|
151
|
+
const deadline = new Promise((resolve) => {
|
|
152
|
+
timeout = setTimeout(() => resolve({ state: "unknown" }), timeoutMs);
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const result = await Promise.race([
|
|
156
|
+
Promise.resolve().then(() => lookup(pid)),
|
|
157
|
+
deadline,
|
|
158
|
+
]);
|
|
159
|
+
if (result.state === "dead" || result.state === "unknown")
|
|
160
|
+
return result;
|
|
161
|
+
if (result.state === "identified" && typeof result.identity === "string"
|
|
162
|
+
&& result.identity.length > 0 && result.identity.length <= 512)
|
|
163
|
+
return result;
|
|
164
|
+
return { state: "unknown" };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return processLiveness(pid) === "dead" ? { state: "dead" } : { state: "unknown" };
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (timeout)
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function parseCandidate(raw) {
|
|
175
|
+
try {
|
|
176
|
+
const value = JSON.parse(raw);
|
|
177
|
+
if (value.version !== 1 || typeof value.instanceId !== "string"
|
|
178
|
+
|| !/^[A-Za-z0-9_-]{16,128}$/.test(value.instanceId)
|
|
179
|
+
|| !Number.isInteger(value.pid) || (value.pid ?? 0) <= 0
|
|
180
|
+
|| typeof value.processIdentity !== "string" || value.processIdentity.length < 1 || value.processIdentity.length > 512
|
|
181
|
+
|| (value.role !== undefined && value.role !== "index-child" && value.role !== "context-child")
|
|
182
|
+
|| (value.role !== undefined && (typeof value.scratchGeneration !== "string"
|
|
183
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.scratchGeneration)))
|
|
184
|
+
|| !Number.isFinite(value.createdAtMs))
|
|
185
|
+
return null;
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function readCandidate(path) {
|
|
193
|
+
try {
|
|
194
|
+
const raw = await readFile(path, "utf8");
|
|
195
|
+
const value = parseCandidate(raw);
|
|
196
|
+
return value ? { state: "present", raw, value } : { state: "invalid" };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return error.code === "ENOENT" ? { state: "missing" } : { state: "invalid" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Legacy ps generations did not record locale/timezone. A changed rendering
|
|
203
|
+
* cannot prove PID reuse across an upgrade; a still-live legacy owner remains
|
|
204
|
+
* uncertain until it exits instead of being mistaken for a stale process. */
|
|
205
|
+
export function classifyDaemonProcessGeneration(owner, expectedIdentity) {
|
|
206
|
+
if (owner.state === "dead")
|
|
207
|
+
return "stale";
|
|
208
|
+
if (owner.state === "unknown")
|
|
209
|
+
return "unknown";
|
|
210
|
+
if (expectedIdentity.startsWith("ps:") && owner.identity.startsWith("ps-utc:"))
|
|
211
|
+
return "unknown";
|
|
212
|
+
return owner.identity === expectedIdentity ? "current" : "stale";
|
|
213
|
+
}
|
|
214
|
+
async function candidateState(candidate, lookup) {
|
|
215
|
+
const owner = await lookup(candidate.pid);
|
|
216
|
+
return classifyDaemonProcessGeneration(owner, candidate.processIdentity);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Moves an immutable candidate out of the election instead of unlinking the
|
|
220
|
+
* pathname observed earlier. The inode/content recheck prevents a stale
|
|
221
|
+
* observer from deleting a successor if storage is concurrently repaired.
|
|
222
|
+
*/
|
|
223
|
+
async function quarantineExact(path, observed, reason) {
|
|
224
|
+
const [current, details] = await Promise.all([
|
|
225
|
+
readFile(path, "utf8").catch(() => null),
|
|
226
|
+
stat(path).catch(() => null),
|
|
227
|
+
]);
|
|
228
|
+
if (current !== observed.raw || !details?.isFile())
|
|
229
|
+
return;
|
|
230
|
+
const fingerprint = createHash("sha256")
|
|
231
|
+
.update(`${observed.value.instanceId}\n${details.dev}:${details.ino}:${details.birthtimeMs}\n${observed.raw}`)
|
|
232
|
+
.digest("hex")
|
|
233
|
+
.slice(0, 20);
|
|
234
|
+
const quarantine = `${path}.${reason}-${fingerprint}-${randomUUID()}`;
|
|
235
|
+
try {
|
|
236
|
+
await rename(path, quarantine);
|
|
237
|
+
await rm(quarantine, { force: true });
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error.code !== "ENOENT")
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function publishCandidate(directory, candidate) {
|
|
245
|
+
const path = join(directory, `${candidate.instanceId}.json`);
|
|
246
|
+
const staging = join(directory, `.${candidate.instanceId}.${randomUUID()}.pending`);
|
|
247
|
+
const handle = await open(staging, "wx", 0o600);
|
|
248
|
+
try {
|
|
249
|
+
await handle.writeFile(JSON.stringify(candidate) + "\n", "utf8");
|
|
250
|
+
await handle.sync();
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await handle.close();
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await rename(staging, path);
|
|
257
|
+
let directoryHandle;
|
|
258
|
+
try {
|
|
259
|
+
directoryHandle = await open(directory, "r");
|
|
260
|
+
await directoryHandle.sync();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (process.platform !== "win32")
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await directoryHandle?.close();
|
|
268
|
+
}
|
|
269
|
+
return path;
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
await rm(staging, { force: true }).catch(() => undefined);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Publish before a worker receives work or SIGSTOP. The immutable candidate
|
|
277
|
+
* outlives its daemon, so a replacement cannot overlap an orphan's last writes.
|
|
278
|
+
* It is not released on parent shutdown until the exact child is proved gone. */
|
|
279
|
+
export async function registerDaemonIndexWorker(dataDir, pid, processIdentity, options = {}) {
|
|
280
|
+
return registerDaemonBackgroundWorker(dataDir, pid, processIdentity, "index-child", options);
|
|
281
|
+
}
|
|
282
|
+
/** Context also writes durable per-session state; a replacement daemon must
|
|
283
|
+
* wait for its exact old generation, even while the orphan watchdog cancels it. */
|
|
284
|
+
export async function registerDaemonContextWorker(dataDir, pid, processIdentity) {
|
|
285
|
+
return registerDaemonBackgroundWorker(dataDir, pid, processIdentity, "context-child");
|
|
286
|
+
}
|
|
287
|
+
async function registerDaemonBackgroundWorker(dataDir, pid, processIdentity, role, options = {}) {
|
|
288
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid
|
|
289
|
+
|| !processIdentity || processIdentity.length > 512)
|
|
290
|
+
throw new Error("Invalid index worker process generation");
|
|
291
|
+
const lookup = (target) => boundedProcessIdentity(options.identityLookup ?? daemonProcessIdentity, target, PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS);
|
|
292
|
+
const current = await lookup(pid);
|
|
293
|
+
if (current.state !== "identified" || current.identity !== processIdentity) {
|
|
294
|
+
throw new Error("Index worker process generation could not be verified before publication");
|
|
295
|
+
}
|
|
296
|
+
const directory = daemonWorkerLeaseDir(dataDir);
|
|
297
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
298
|
+
const candidate = {
|
|
299
|
+
version: 1, role, scratchGeneration: randomUUID(), instanceId: randomBytes(24).toString("base64url"),
|
|
300
|
+
pid, processIdentity, createdAtMs: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
const path = await publishCandidate(directory, candidate);
|
|
303
|
+
const observed = { raw: JSON.stringify(candidate) + "\n", value: candidate };
|
|
304
|
+
return {
|
|
305
|
+
scratchGeneration: candidate.scratchGeneration,
|
|
306
|
+
async retireAfterExit() {
|
|
307
|
+
if (await candidateState(candidate, lookup) !== "stale") {
|
|
308
|
+
throw new Error("A live or unverified index worker still owns its startup barrier");
|
|
309
|
+
}
|
|
310
|
+
if (role === "index-child")
|
|
311
|
+
await cleanupInterruptedIndexScratch(dataDir, candidate.scratchGeneration);
|
|
312
|
+
await quarantineExact(path, observed, "released");
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Elects the sole complete daemon worker for this vault.
|
|
318
|
+
*
|
|
319
|
+
* `null` means another identified generation owns the vault, or an existing
|
|
320
|
+
* record could not safely be classified. Uncertainty fails closed: preserving
|
|
321
|
+
* availability never justifies two retention/index writers.
|
|
322
|
+
*/
|
|
323
|
+
export async function acquireDaemonWorkerLease(dataDir, options = {}) {
|
|
324
|
+
const lookup = options.identityLookup ?? daemonProcessIdentity;
|
|
325
|
+
const lookupTimeoutMs = Math.max(1, options.identityLookupTimeoutMs ?? PROCESS_IDENTITY_LOOKUP_TIMEOUT_MS);
|
|
326
|
+
// One election can observe several immutable candidates for the same process
|
|
327
|
+
// (including its freshly published candidate). Resolve that generation once
|
|
328
|
+
// so one delayed `ps` cannot be multiplied by the number of lease files.
|
|
329
|
+
const generations = new Map();
|
|
330
|
+
const lookupOnce = (pid) => {
|
|
331
|
+
let pending = generations.get(pid);
|
|
332
|
+
if (!pending) {
|
|
333
|
+
pending = boundedProcessIdentity(lookup, pid, lookupTimeoutMs);
|
|
334
|
+
generations.set(pid, pending);
|
|
335
|
+
}
|
|
336
|
+
return pending;
|
|
337
|
+
};
|
|
338
|
+
const self = await lookupOnce(process.pid);
|
|
339
|
+
if (self.state !== "identified") {
|
|
340
|
+
throw new Error("Sealkeep could not verify this process generation, so it refused to start a vault worker");
|
|
341
|
+
}
|
|
342
|
+
const directory = daemonWorkerLeaseDir(dataDir);
|
|
343
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
344
|
+
const candidate = {
|
|
345
|
+
version: 1,
|
|
346
|
+
instanceId: randomBytes(24).toString("base64url"),
|
|
347
|
+
pid: process.pid,
|
|
348
|
+
processIdentity: self.identity,
|
|
349
|
+
createdAtMs: Date.now(),
|
|
350
|
+
};
|
|
351
|
+
const path = await publishCandidate(directory, candidate);
|
|
352
|
+
const observedSelf = { raw: JSON.stringify(candidate) + "\n", value: candidate };
|
|
353
|
+
const release = () => quarantineExact(path, observedSelf, "released");
|
|
354
|
+
const assertOwned = async () => {
|
|
355
|
+
const observed = await readCandidate(path);
|
|
356
|
+
if (observed.state !== "present" || observed.raw !== observedSelf.raw
|
|
357
|
+
|| observed.value.instanceId !== candidate.instanceId
|
|
358
|
+
|| observed.value.pid !== candidate.pid
|
|
359
|
+
|| observed.value.processIdentity !== candidate.processIdentity) {
|
|
360
|
+
throw new Error("Sealkeep no longer owns the daemon worker lease for this vault");
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
await pause(Math.max(0, options.electionWindowMs ?? ELECTION_WINDOW_MS));
|
|
364
|
+
const entries = await readdir(directory).catch(() => []);
|
|
365
|
+
const active = [];
|
|
366
|
+
let uncertain = false;
|
|
367
|
+
let liveIndexChild = false;
|
|
368
|
+
for (const name of entries) {
|
|
369
|
+
if (!name.endsWith(".json"))
|
|
370
|
+
continue;
|
|
371
|
+
const candidatePath = join(directory, name);
|
|
372
|
+
const observed = await readCandidate(candidatePath);
|
|
373
|
+
// A losing contender can remove its uniquely named immutable candidate
|
|
374
|
+
// after our readdir snapshot. Its absence is normal contention, not
|
|
375
|
+
// evidence of a corrupt possibly-live owner.
|
|
376
|
+
if (observed.state === "missing")
|
|
377
|
+
continue;
|
|
378
|
+
// Published candidates are written+fsynced before atomic rename. An
|
|
379
|
+
// invalid JSON record therefore means corruption, not an in-flight write.
|
|
380
|
+
if (observed.state === "invalid") {
|
|
381
|
+
uncertain = true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const state = await candidateState(observed.value, lookupOnce);
|
|
385
|
+
if (state === "stale") {
|
|
386
|
+
if (observed.value.role === "index-child") {
|
|
387
|
+
try {
|
|
388
|
+
await cleanupInterruptedIndexScratch(dataDir, observed.value.scratchGeneration);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
uncertain = true;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
await quarantineExact(candidatePath, observed, "stale").catch(() => { uncertain = true; });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (state === "unknown") {
|
|
399
|
+
uncertain = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (observed.value.role === "index-child" || observed.value.role === "context-child")
|
|
403
|
+
liveIndexChild = true;
|
|
404
|
+
else
|
|
405
|
+
active.push(observed.value);
|
|
406
|
+
}
|
|
407
|
+
active.sort((left, right) => left.createdAtMs - right.createdAtMs || left.instanceId.localeCompare(right.instanceId));
|
|
408
|
+
if (uncertain || liveIndexChild || active[0]?.instanceId !== candidate.instanceId) {
|
|
409
|
+
await release().catch(() => undefined);
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
instanceId: candidate.instanceId,
|
|
414
|
+
pid: candidate.pid,
|
|
415
|
+
processIdentity: candidate.processIdentity,
|
|
416
|
+
path,
|
|
417
|
+
assertOwned,
|
|
418
|
+
release,
|
|
419
|
+
};
|
|
420
|
+
}
|