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.
Files changed (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1746 -166
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5897 -290
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +235 -29
  249. package/dist/src/vault.js +1783 -190
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,49 +1,345 @@
1
- import { access, constants } from "node:fs/promises";
2
- import { basename, resolve } from "node:path";
3
- import { listArchives } from "./vault.js";
4
- import { restoreArchive } from "./restore.js";
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
- return { rehydrated: false, reason: "already-present" };
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
- const byPath = wanted ? records.filter((record) => record.source.path === wanted) : [];
17
- const byName = byPath.length > 0 ? byPath
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
- return { rehydrated: false, reason: "not-archived" };
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
- // If the match came by name and the file exists at ITS recorded path, the
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 outcome = await restoreArchive(dataDir, newest.id, phrase, { native: true, overwrite: "refuse", home: options.home });
35
- await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: newest.id, path: outcome.output, rehydrated: true });
36
- return { rehydrated: true, archiveId: newest.id, output: outcome.output, bytes: outcome.bytes };
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
- * Lenient hook-payload reading, distinct from `hookEventFromStdin` on purpose:
44
- * that parser refuses when the transcript is unreadable, and an ABSENT
45
- * transcript is this feature's entire subject.
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 {
@@ -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 renamed
29
- * into place, so a crash never leaves a half-written transcript — and a transcript
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; `backup` preserves it beside the restored copy and
32
- * `replace` is the only policy that discards it.
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>;