sealkeep 0.8.1 → 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 +244 -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 +1746 -166
- 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.d.ts +1 -0
- package/dist/src/migrate.js +33 -5
- 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 +235 -29
- package/dist/src/vault.js +1783 -190
- 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
package/dist/src/rehydrate.js
CHANGED
|
@@ -1,49 +1,345 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { access, constants, open, rm, stat, utimes } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { createGunzip } from "node:zlib";
|
|
7
|
+
import { listArchives, assertPhraseOpens, findArchive, readConfig, restoreRecordToFile } from "./vault.js";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { nativeRestoreTarget, validateNativeRestoreOutput } from "./restore.js";
|
|
5
10
|
import { recordAudit } from "./audit.js";
|
|
11
|
+
import { readUntouchedCodexResumeStub } from "./resume-stub.js";
|
|
12
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
13
|
+
import { MAX_FRAME_METADATA_BYTES } from "./byte-stream.js";
|
|
14
|
+
import { hashFileRange, openArchiveToFile } from "../packages/sealkeep-crypto/src/index.js";
|
|
15
|
+
import { parkExistingPathNoClobber, publishTempCreateIfAbsent, restoreParkedCreateIfAbsent, } from "./reclaim-transaction.js";
|
|
16
|
+
const expectedStubFile = (stub) => ({ bytes: stub.bytes, sha256: stub.sha256 });
|
|
17
|
+
/**
|
|
18
|
+
* Commit restored bytes without overwriting anything written during the slow
|
|
19
|
+
* decrypt/download. Existing pointer bytes are first parked under a unique
|
|
20
|
+
* hidden name, then re-read and digest-checked by the no-clobber primitive.
|
|
21
|
+
* The parked inode is retained: an agent with an already-open file descriptor
|
|
22
|
+
* may append after it moves, and those bytes must remain recoverable even when
|
|
23
|
+
* the restore succeeds.
|
|
24
|
+
*/
|
|
25
|
+
async function commitRestoredTranscript(temp, output, expectedStub, originalTimes) {
|
|
26
|
+
if (!expectedStub) {
|
|
27
|
+
const restored = await hashFileRange(temp);
|
|
28
|
+
await publishTempCreateIfAbsent(temp, output, restored);
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const parked = join(dirname(output), `.${basename(output)}.${randomUUID()}.sealkeep-pointer-backup`);
|
|
32
|
+
try {
|
|
33
|
+
await parkExistingPathNoClobber(output, parked, expectedStubFile(expectedStub));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new Error(`The resume pointer changed while the transcript was restoring; its bytes were preserved and the restore was refused (${error instanceof Error ? error.message : "conflict"})`);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const restored = await hashFileRange(temp);
|
|
40
|
+
await publishTempCreateIfAbsent(temp, output, restored);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await restoreParkedCreateIfAbsent(parked, output).catch(() => undefined);
|
|
44
|
+
throw new Error(`The native transcript path was recreated while the transcript was restoring; both existing copies were preserved (${error instanceof Error ? error.message : "conflict"})`);
|
|
45
|
+
}
|
|
46
|
+
if (originalTimes)
|
|
47
|
+
await utimes(output, originalTimes.atime, originalTimes.mtime).catch(() => undefined);
|
|
48
|
+
return { pointerBackup: parked };
|
|
49
|
+
}
|
|
50
|
+
function verifiedLocalPointer(record, wanted, stub) {
|
|
51
|
+
return record.id === stub.archiveId
|
|
52
|
+
&& record.source.path === wanted
|
|
53
|
+
&& record.reclaimed?.resumeStub?.version === 1
|
|
54
|
+
&& record.reclaimed.resumeStub.sessionId === stub.sessionId
|
|
55
|
+
&& record.reclaimed.resumeStub.bytes === stub.bytes
|
|
56
|
+
&& record.reclaimed.resumeStub.sha256 === stub.sha256;
|
|
57
|
+
}
|
|
6
58
|
export async function rehydrateSession(dataDir, target, phrase, options = {}) {
|
|
7
59
|
const wanted = target.path ? resolve(target.path) : null;
|
|
8
60
|
if (!wanted && !target.sessionId)
|
|
9
61
|
return { rehydrated: false, reason: "no-target" };
|
|
62
|
+
let resumeStub = null;
|
|
10
63
|
if (wanted) {
|
|
11
64
|
const present = await access(wanted, constants.R_OK).then(() => true, () => false);
|
|
12
|
-
if (present)
|
|
13
|
-
|
|
65
|
+
if (present) {
|
|
66
|
+
resumeStub = await readUntouchedCodexResumeStub(wanted);
|
|
67
|
+
if (!resumeStub)
|
|
68
|
+
return { rehydrated: false, reason: "already-present" };
|
|
69
|
+
}
|
|
14
70
|
}
|
|
15
71
|
const records = (await listArchives(dataDir)).filter((record) => record.version === 2);
|
|
16
|
-
|
|
17
|
-
|
|
72
|
+
// A pointer names the exact verified snapshot it replaced. Never substitute
|
|
73
|
+
// a different archive just because it shares a path: a later archive may be
|
|
74
|
+
// a continuation, and an earlier one may omit the end of the session.
|
|
75
|
+
const byStub = resumeStub
|
|
76
|
+
? records.filter((record) => verifiedLocalPointer(record, wanted, resumeStub))
|
|
77
|
+
: [];
|
|
78
|
+
const localKnowsPointerArchive = Boolean(resumeStub && records.some((record) => record.id === resumeStub.archiveId));
|
|
79
|
+
if (resumeStub && localKnowsPointerArchive && byStub.length === 0) {
|
|
80
|
+
return { rehydrated: false, reason: "restore-failed", note: "The resume pointer does not match its immutable reclaim record; nothing was overwritten." };
|
|
81
|
+
}
|
|
82
|
+
const byPath = resumeStub ? byStub : wanted ? records.filter((record) => record.source.path === wanted) : [];
|
|
83
|
+
const byName = resumeStub ? byPath : byPath.length > 0 ? byPath
|
|
18
84
|
: wanted ? records.filter((record) => basename(record.source.path) === basename(wanted)) : [];
|
|
19
|
-
const bySession = byName.length > 0 ? byName
|
|
85
|
+
const bySession = resumeStub ? byName : byName.length > 0 ? byName
|
|
20
86
|
: target.sessionId ? records.filter((record) => basename(record.source.path).includes(target.sessionId)) : [];
|
|
21
|
-
if (bySession.length === 0)
|
|
22
|
-
|
|
87
|
+
if (bySession.length === 0) {
|
|
88
|
+
try {
|
|
89
|
+
const remote = await rehydrateManaged(dataDir, target, phrase, options, resumeStub);
|
|
90
|
+
if (remote)
|
|
91
|
+
return remote;
|
|
92
|
+
return { rehydrated: false, reason: phrase ? "not-archived" : "no-phrase" };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { rehydrated: false, reason: "restore-failed", note: error instanceof Error ? error.message.split("\n")[0] : "managed restore failed" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
23
98
|
const newest = bySession.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
|
|
24
|
-
|
|
25
|
-
// catalog and the disk already agree — nothing to do.
|
|
26
|
-
if (!wanted || newest.source.path !== wanted) {
|
|
99
|
+
if (!resumeStub && (!wanted || newest.source.path !== wanted)) {
|
|
27
100
|
const there = await access(newest.source.path, constants.R_OK).then(() => true, () => false);
|
|
28
101
|
if (there)
|
|
29
102
|
return { rehydrated: false, reason: "already-present" };
|
|
30
103
|
}
|
|
31
104
|
if (!phrase)
|
|
32
105
|
return { rehydrated: false, reason: "no-phrase", note: "No recovery phrase is available to this machine (keystore or SEALKEEP_RECOVERY_PHRASE), so the sealed copy stays sealed." };
|
|
106
|
+
let temp = null;
|
|
33
107
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
108
|
+
const record = await findArchive(dataDir, newest.id);
|
|
109
|
+
const output = nativeRestoreTarget(record, options.home);
|
|
110
|
+
if (wanted && output !== wanted)
|
|
111
|
+
throw new Error("The archived native path does not match the requested transcript path");
|
|
112
|
+
assertPhraseOpens(await readConfig(dataDir), record, phrase);
|
|
113
|
+
await validateNativeRestoreOutput(output, options.home ?? homedir(), resumeStub ? "file" : "missing-or-file");
|
|
114
|
+
temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.rehydrate-partial`);
|
|
115
|
+
const restored = await restoreRecordToFile(dataDir, record, phrase, temp);
|
|
116
|
+
const originalTimes = resumeStub && wanted ? await stat(wanted).catch(() => null) : null;
|
|
117
|
+
await options.operations?.beforeCommit?.({ archiveId: newest.id, output, managed: false });
|
|
118
|
+
const committed = await commitRestoredTranscript(temp, output, resumeStub, originalTimes);
|
|
119
|
+
temp = null;
|
|
120
|
+
await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: newest.id, path: output, rehydrated: true });
|
|
121
|
+
return { rehydrated: true, archiveId: newest.id, output, bytes: restored.bytes, ...committed };
|
|
37
122
|
}
|
|
38
123
|
catch (error) {
|
|
39
124
|
return { rehydrated: false, reason: "restore-failed", note: error instanceof Error ? error.message.split("\n")[0] : "restore failed" };
|
|
40
125
|
}
|
|
126
|
+
finally {
|
|
127
|
+
if (temp)
|
|
128
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
129
|
+
}
|
|
41
130
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
131
|
+
function validCatalogFacts(value) {
|
|
132
|
+
return value?.v === 1
|
|
133
|
+
&& typeof value.createdAt === "string"
|
|
134
|
+
&& Number.isSafeInteger(value.sourceBytes) && value.sourceBytes >= 0
|
|
135
|
+
&& /^[0-9a-f]{64}$/i.test(value.sourceSha256)
|
|
136
|
+
&& Number.isSafeInteger(value.storedBytes) && value.storedBytes >= 0
|
|
137
|
+
&& typeof value.algorithm === "string"
|
|
138
|
+
&& Number.isSafeInteger(value.chunks) && value.chunks >= 0
|
|
139
|
+
&& (!value.delta || (typeof value.delta.baseArchiveId === "string"
|
|
140
|
+
&& Number.isSafeInteger(value.delta.baseBytes) && value.delta.baseBytes > 0));
|
|
141
|
+
}
|
|
142
|
+
/** Resolve oldest-to-newest and refuse every incomplete, cyclic or inconsistent delta chain. */
|
|
143
|
+
function managedDeltaChain(selected, entries, durable) {
|
|
144
|
+
const newestToOldest = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
let cursor = selected;
|
|
147
|
+
while (cursor) {
|
|
148
|
+
if (seen.has(cursor.id) || !durable.has(cursor.id) || !validCatalogFacts(cursor.facts))
|
|
149
|
+
return null;
|
|
150
|
+
seen.add(cursor.id);
|
|
151
|
+
newestToOldest.push(cursor);
|
|
152
|
+
const delta = cursor.facts.delta;
|
|
153
|
+
if (!delta)
|
|
154
|
+
break;
|
|
155
|
+
const base = entries.get(delta.baseArchiveId);
|
|
156
|
+
if (!base || base.facts.sourceBytes !== delta.baseBytes || cursor.facts.sourceBytes <= delta.baseBytes)
|
|
157
|
+
return null;
|
|
158
|
+
cursor = base;
|
|
159
|
+
}
|
|
160
|
+
const chain = newestToOldest.reverse();
|
|
161
|
+
if (chain.length === 0 || chain[0].facts.delta)
|
|
162
|
+
return null;
|
|
163
|
+
return chain;
|
|
164
|
+
}
|
|
165
|
+
const FRAME_MAGIC = Buffer.from("VLA1", "ascii");
|
|
166
|
+
async function downloadManagedRef(dataDir, ref, destination) {
|
|
167
|
+
const { pullCiphertextStream } = await import("./cloud.js");
|
|
168
|
+
await pipeline(await pullCiphertextStream(dataDir, ref), createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
169
|
+
}
|
|
170
|
+
async function framedEnvelope(path) {
|
|
171
|
+
const handle = await open(path, "r");
|
|
172
|
+
try {
|
|
173
|
+
const info = await handle.stat();
|
|
174
|
+
const prefix = Buffer.alloc(Math.min(8, info.size));
|
|
175
|
+
if (prefix.length > 0)
|
|
176
|
+
await handle.read(prefix, 0, prefix.length, 0);
|
|
177
|
+
if (prefix.length < 8 || !prefix.subarray(0, 4).equals(FRAME_MAGIC)) {
|
|
178
|
+
return { envelope: null, bodyOffset: 0, gzip: prefix.length >= 2 && prefix[0] === 0x1f && prefix[1] === 0x8b };
|
|
179
|
+
}
|
|
180
|
+
const metadataBytes = prefix.readUInt32BE(4);
|
|
181
|
+
if (metadataBytes > MAX_FRAME_METADATA_BYTES)
|
|
182
|
+
throw new Error(`Managed archive metadata is ${metadataBytes} bytes; the safe limit is ${MAX_FRAME_METADATA_BYTES}`);
|
|
183
|
+
if (info.size < 8 + metadataBytes)
|
|
184
|
+
throw new Error("Managed archive header is truncated");
|
|
185
|
+
const metadata = Buffer.alloc(metadataBytes);
|
|
186
|
+
if (metadataBytes > 0) {
|
|
187
|
+
const result = await handle.read(metadata, 0, metadataBytes, 8);
|
|
188
|
+
if (result.bytesRead !== metadataBytes)
|
|
189
|
+
throw new Error("Managed archive header is truncated");
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
return { envelope: JSON.parse(metadata.toString("utf8")), bodyOffset: 8 + metadataBytes, gzip: false };
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new Error("Managed archive header is not readable JSON");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await handle.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Open one physical managed archive to plaintext with bounded RAM. */
|
|
203
|
+
async function openManagedArchiveToTemp(dataDir, archiveId, destination, phrase) {
|
|
204
|
+
const { statCiphertext, pullCiphertextStream } = await import("./cloud.js");
|
|
205
|
+
const { managedRefForKey } = await import("./managed-chunks.js");
|
|
206
|
+
const { chunkObjectName, ENVELOPE_OBJECT } = await import("./chunk-store.js");
|
|
207
|
+
const scratch = `${destination}.${randomUUID()}`;
|
|
208
|
+
const downloaded = `${scratch}.managed`;
|
|
209
|
+
const ciphertext = `${scratch}.ciphertext`;
|
|
210
|
+
try {
|
|
211
|
+
let envelope = null;
|
|
212
|
+
if ((await statCiphertext(dataDir, archiveId)).exists) {
|
|
213
|
+
await downloadManagedRef(dataDir, archiveId, downloaded);
|
|
214
|
+
const framed = await framedEnvelope(downloaded);
|
|
215
|
+
if (!framed.envelope) {
|
|
216
|
+
if (!framed.gzip)
|
|
217
|
+
throw new Error(`${archiveId} is a legacy managed object without a self-contained envelope`);
|
|
218
|
+
await pipeline(createReadStream(downloaded), createGunzip(), createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
219
|
+
return hashFileRange(destination);
|
|
220
|
+
}
|
|
221
|
+
envelope = framed.envelope;
|
|
222
|
+
await pipeline(createReadStream(downloaded, { start: framed.bodyOffset }), createWriteStream(ciphertext, { flags: "wx", mode: 0o600 }));
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const sidecarRef = managedRefForKey(`${archiveId}/${ENVELOPE_OBJECT}`);
|
|
226
|
+
if (!(await statCiphertext(dataDir, sidecarRef)).exists)
|
|
227
|
+
throw new Error(`Managed storage holds no complete archive ${archiveId}`);
|
|
228
|
+
await downloadManagedRef(dataDir, sidecarRef, downloaded);
|
|
229
|
+
const framed = await framedEnvelope(downloaded);
|
|
230
|
+
if (!framed.envelope)
|
|
231
|
+
throw new Error(`The managed sidecar for ${archiveId} carries no envelope`);
|
|
232
|
+
envelope = framed.envelope;
|
|
233
|
+
const headers = Array.isArray(envelope.chunks) ? envelope.chunks : [];
|
|
234
|
+
if (headers.length === 0)
|
|
235
|
+
throw new Error(`The managed sidecar for ${archiveId} lists no chunks`);
|
|
236
|
+
for (let index = 0; index < headers.length; index += 1) {
|
|
237
|
+
const ref = managedRefForKey(`${archiveId}/${chunkObjectName(index)}`);
|
|
238
|
+
await pipeline(await pullCiphertextStream(dataDir, ref), createWriteStream(ciphertext, { flags: index === 0 ? "wx" : "a", mode: 0o600 }));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const opened = await openArchiveToFile(envelope, ciphertext, destination, { phrase: canonicalPhrase(phrase) });
|
|
242
|
+
return { bytes: opened.bytes, sha256: opened.sha256 };
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await rm(downloaded, { force: true }).catch(() => undefined);
|
|
246
|
+
await rm(ciphertext, { force: true }).catch(() => undefined);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Restore a fresh machine only from a complete verified managed delta chain. */
|
|
250
|
+
async function rehydrateManaged(dataDir, target, phrase, options, knownStub = null) {
|
|
251
|
+
if (!phrase)
|
|
252
|
+
return null;
|
|
253
|
+
const { refreshIndexFromRemote, loadContentIndexForQuery } = await import("./search.js");
|
|
254
|
+
let index;
|
|
255
|
+
try {
|
|
256
|
+
await refreshIndexFromRemote(dataDir, phrase);
|
|
257
|
+
// V3 parsing streams the expanded checkpoint and keeps catalog/meta rows
|
|
258
|
+
// only. It does not materialize the token map that reached 808 MB in a
|
|
259
|
+
// production vault. No search terms are needed for an id/path lookup.
|
|
260
|
+
index = await loadContentIndexForQuery(dataDir, phrase, []);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof Error && /No (?:local )?content index(?: this key can open)?/i.test(error.message))
|
|
264
|
+
return null;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
const wanted = target.path ? resolve(target.path) : null;
|
|
268
|
+
const allEntries = Object.entries(index.archives).flatMap(([id, row]) => {
|
|
269
|
+
const agent = row[0] ?? "";
|
|
270
|
+
const path = row[1] ?? "";
|
|
271
|
+
const meta = index.meta?.[id];
|
|
272
|
+
return (agent === "codex" || agent === "claude") && validCatalogFacts(meta?.archiveRecord)
|
|
273
|
+
? [{ id, agent, path, meta, facts: meta.archiveRecord }]
|
|
274
|
+
: [];
|
|
275
|
+
});
|
|
276
|
+
const entries = new Map(allEntries.map((entry) => [entry.id, entry]));
|
|
277
|
+
const matches = allEntries.filter((entry) => (wanted && (resolve(entry.path) === wanted || basename(entry.path) === basename(wanted)))
|
|
278
|
+
|| (target.sessionId && (entry.id.includes(target.sessionId) || basename(entry.path).includes(target.sessionId))));
|
|
279
|
+
if (matches.length === 0)
|
|
280
|
+
return null;
|
|
281
|
+
const { listCloudArchives } = await import("./cloud.js");
|
|
282
|
+
const { collapseCloudRows } = await import("./managed-chunks.js");
|
|
283
|
+
const durableRows = collapseCloudRows((await listCloudArchives(dataDir)).filter((row) => row.state === "durable"));
|
|
284
|
+
const durable = new Set(durableRows.map((row) => row.vault_ref));
|
|
285
|
+
const candidates = matches.filter((entry) => durable.has(entry.id) && (!knownStub || entry.id === knownStub.archiveId));
|
|
286
|
+
if (candidates.length === 0)
|
|
287
|
+
return null;
|
|
288
|
+
candidates.sort((a, b) => {
|
|
289
|
+
const at = a.meta?.endedAt ?? a.meta?.startedAt ?? a.facts.createdAt;
|
|
290
|
+
const bt = b.meta?.endedAt ?? b.meta?.startedAt ?? b.facts.createdAt;
|
|
291
|
+
return bt.localeCompare(at) || b.id.localeCompare(a.id);
|
|
292
|
+
});
|
|
293
|
+
const selected = candidates[0];
|
|
294
|
+
const chain = managedDeltaChain(selected, entries, durable);
|
|
295
|
+
if (!chain)
|
|
296
|
+
return null;
|
|
297
|
+
const output = wanted ?? resolve(selected.path);
|
|
298
|
+
const synthetic = {
|
|
299
|
+
version: 2, id: selected.id, createdAt: selected.facts.createdAt,
|
|
300
|
+
source: { path: output, agent: selected.agent, bytes: selected.facts.sourceBytes, sha256: selected.facts.sourceSha256 },
|
|
301
|
+
cipher: { algorithm: selected.facts.algorithm, ciphertextSha256: selected.facts.ciphertextSha256, storedBytes: selected.facts.storedBytes, chunks: selected.facts.chunks },
|
|
302
|
+
envelope: {}, objectPath: "",
|
|
303
|
+
};
|
|
304
|
+
const nativeOutput = nativeRestoreTarget(synthetic, options.home);
|
|
305
|
+
const existing = await access(nativeOutput, constants.R_OK).then(() => true, () => false);
|
|
306
|
+
const stub = knownStub ?? (existing ? await readUntouchedCodexResumeStub(nativeOutput) : null);
|
|
307
|
+
if (existing && !stub)
|
|
308
|
+
return null;
|
|
309
|
+
const originalTimes = stub ? await stat(nativeOutput).catch(() => null) : null;
|
|
310
|
+
await validateNativeRestoreOutput(nativeOutput, options.home ?? homedir(), stub ? "file" : "missing-or-file");
|
|
311
|
+
let assembled = join(dirname(nativeOutput), `.${basename(nativeOutput)}.${randomUUID()}.rehydrate-partial`);
|
|
312
|
+
try {
|
|
313
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
314
|
+
const entry = chain[index];
|
|
315
|
+
const piece = `${assembled}.${index}.piece`;
|
|
316
|
+
try {
|
|
317
|
+
const opened = await openManagedArchiveToTemp(dataDir, entry.id, piece, phrase);
|
|
318
|
+
const expectedBytes = index === 0 ? entry.facts.sourceBytes : entry.facts.sourceBytes - entry.facts.delta.baseBytes;
|
|
319
|
+
if (opened.bytes !== expectedBytes)
|
|
320
|
+
throw new Error(`Managed archive ${entry.id} produced ${opened.bytes} bytes; its authenticated catalog requires ${expectedBytes}`);
|
|
321
|
+
await pipeline(createReadStream(piece), createWriteStream(assembled, { flags: index === 0 ? "wx" : "a", mode: 0o600 }));
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await rm(piece, { force: true }).catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const complete = await hashFileRange(assembled);
|
|
328
|
+
if (complete.bytes !== selected.facts.sourceBytes || complete.sha256.toLowerCase() !== selected.facts.sourceSha256.toLowerCase()) {
|
|
329
|
+
throw new Error(`Managed delta chain for ${selected.id} did not reproduce its authenticated source digest`);
|
|
330
|
+
}
|
|
331
|
+
await options.operations?.beforeCommit?.({ archiveId: selected.id, output: nativeOutput, managed: true });
|
|
332
|
+
const committed = await commitRestoredTranscript(assembled, nativeOutput, stub, originalTimes);
|
|
333
|
+
assembled = null;
|
|
334
|
+
await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: selected.id, path: nativeOutput, rehydrated: true, managed: true });
|
|
335
|
+
return { rehydrated: true, archiveId: selected.id, output: nativeOutput, bytes: complete.bytes, ...committed };
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
if (assembled)
|
|
339
|
+
await rm(assembled, { force: true }).catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Lenient because an absent transcript is this feature's subject. */
|
|
47
343
|
export function rehydrateTargetFromPayload(stdin) {
|
|
48
344
|
try {
|
|
49
345
|
const payload = JSON.parse(stdin);
|
|
@@ -57,7 +353,7 @@ export function rehydrateTargetFromPayload(stdin) {
|
|
|
57
353
|
};
|
|
58
354
|
return {
|
|
59
355
|
path: pick(["transcript_path", "transcriptPath", "rollout_path", "rolloutPath"]),
|
|
60
|
-
sessionId: pick(["session_id", "sessionId", "conversation_id", "thread_id"])
|
|
356
|
+
sessionId: pick(["session_id", "sessionId", "conversation_id", "thread_id"]),
|
|
61
357
|
};
|
|
62
358
|
}
|
|
63
359
|
catch {
|
package/dist/src/restore.d.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import type { AgentId } from "./adapters.js";
|
|
2
2
|
import type { ArchiveRecord } from "./types.js";
|
|
3
|
+
import { type SourceReference } from "./source-reader.js";
|
|
3
4
|
export type OverwritePolicy = "refuse" | "backup" | "replace";
|
|
5
|
+
export type RestoreOperations = {
|
|
6
|
+
/** Deterministic race seams for tests; production callers never provide these. */
|
|
7
|
+
beforeCommit?: (input: {
|
|
8
|
+
output: string;
|
|
9
|
+
policy: OverwritePolicy;
|
|
10
|
+
}) => void | Promise<void>;
|
|
11
|
+
beforePark?: (input: {
|
|
12
|
+
output: string;
|
|
13
|
+
parkPath: string;
|
|
14
|
+
policy: "backup" | "replace";
|
|
15
|
+
}) => void | Promise<void>;
|
|
16
|
+
afterPark?: (input: {
|
|
17
|
+
output: string;
|
|
18
|
+
parkPath: string;
|
|
19
|
+
policy: "backup" | "replace";
|
|
20
|
+
}) => void | Promise<void>;
|
|
21
|
+
};
|
|
4
22
|
export type RestoreOptions = {
|
|
23
|
+
source?: SourceReference;
|
|
24
|
+
project?: string;
|
|
25
|
+
projectKey?: string;
|
|
5
26
|
destination?: string;
|
|
6
27
|
native?: boolean;
|
|
7
28
|
overwrite?: OverwritePolicy;
|
|
8
29
|
home?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Report what would be restored — record, byte count, destination, whether a
|
|
32
|
+
* file already sits there — without creating or modifying anything. The
|
|
33
|
+
* shared-memory context pack promises agents that recovery previews by
|
|
34
|
+
* default, so the preview path must exist as a first-class outcome.
|
|
35
|
+
*/
|
|
36
|
+
dryRun?: boolean;
|
|
37
|
+
/** Internal deterministic test seam. */
|
|
38
|
+
operations?: RestoreOperations;
|
|
9
39
|
};
|
|
10
40
|
export type RestoreOutcome = {
|
|
11
41
|
record: ArchiveRecord;
|
|
@@ -13,6 +43,8 @@ export type RestoreOutcome = {
|
|
|
13
43
|
bytes: number;
|
|
14
44
|
native: boolean;
|
|
15
45
|
backupPath?: string;
|
|
46
|
+
dryRun?: boolean;
|
|
47
|
+
destinationExists?: boolean;
|
|
16
48
|
};
|
|
17
49
|
/**
|
|
18
50
|
* Where each adapter's transcripts legitimately live. Restoring "natively" means
|
|
@@ -22,15 +54,36 @@ export type RestoreOutcome = {
|
|
|
22
54
|
export declare const NATIVE_ROOTS: Record<AgentId, (home: string) => string[]>;
|
|
23
55
|
/** Resolves the adapter-native destination, refusing anything outside the agent's roots. */
|
|
24
56
|
export declare function nativeRestoreTarget(record: ArchiveRecord, home?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Build and verify a native transcript parent without following a symlinked
|
|
59
|
+
* `.codex`, `.claude`, sessions root, or date/project directory. The lexical
|
|
60
|
+
* target still remains the adapter's recorded path; canonicalisation here
|
|
61
|
+
* only makes platform aliases such as macOS `/var` -> `/private/var` safe to
|
|
62
|
+
* compare.
|
|
63
|
+
*/
|
|
64
|
+
export declare function validateNativeRestoreOutput(output: string, home?: string, leaf?: "file" | "missing-or-file"): Promise<void>;
|
|
25
65
|
/**
|
|
26
66
|
* Restores one archive atomically.
|
|
27
67
|
*
|
|
28
|
-
* The plaintext is streamed to a temporary file beside the destination and
|
|
29
|
-
*
|
|
68
|
+
* The plaintext is streamed to a temporary file beside the destination and
|
|
69
|
+
* published with create-if-absent semantics, so a crash never leaves a
|
|
70
|
+
* half-written transcript — and a transcript
|
|
30
71
|
* of any size is restored without ever holding it in memory. An existing file
|
|
31
|
-
* is refused by default
|
|
32
|
-
* `replace`
|
|
72
|
+
* is refused by default. `backup` preserves the displaced inode beside the
|
|
73
|
+
* restored copy. `replace` replaces the active destination too, but retains
|
|
74
|
+
* the displaced inode under a visible `.sealkeep-replaced-*` safety path: no
|
|
75
|
+
* portable filesystem primitive can prove that another process has stopped
|
|
76
|
+
* writing through an already-open descriptor.
|
|
33
77
|
*/
|
|
34
78
|
export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions & {
|
|
35
79
|
privateKey?: string;
|
|
36
80
|
}): Promise<RestoreOutcome>;
|
|
81
|
+
/**
|
|
82
|
+
* Proves an archive can produce the bytes it claims, before anything is taken.
|
|
83
|
+
*
|
|
84
|
+
* Decrypts the archive to a scratch file and hashes the result. Nothing else
|
|
85
|
+
* in this file is allowed to trash a source until this has returned true —
|
|
86
|
+
* the point is to catch a codec or storage fault while the original is still
|
|
87
|
+
* on disk, which is the only moment catching it is worth anything.
|
|
88
|
+
*/
|
|
89
|
+
export declare function verifyArchiveReadsBack(dataDir: string, archiveId: string, phrase: string, expectedSha256: string): Promise<boolean>;
|