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,25 +1,77 @@
1
- import { createReadStream } from "node:fs";
2
1
  import { stat } from "node:fs/promises";
3
- import { createHash } from "node:crypto";
4
2
  import { hostname } from "node:os";
5
3
  import { resolve } from "node:path";
6
4
  import { ArchiveQueue } from "./queue.js";
7
5
  import { archiveFile, listArchives, readConfig } from "./vault.js";
8
- import { isVaultlineError } from "./errors.js";
9
- import { roomToArchive, sealReservations, spaceNeededFor } from "./disk.js";
6
+ import { isSealkeepError } from "./errors.js";
7
+ import { LOCAL_SEAL_CHUNK_BYTES, roomToArchive, sealReservations, spaceNeededFor } from "./disk.js";
10
8
  import { isV2 } from "./types.js";
9
+ import { automaticTranscriptIsEnabled, backgroundUploadBytesPerSecond, readLocalSettings } from "./machine-settings.js";
10
+ import { hashFileRange } from "../packages/sealkeep-crypto/src/index.js";
11
+ import { createProgressDeadline } from "./progress-deadline.js";
12
+ import { readUntouchedCodexResumeStub } from "./resume-stub.js";
13
+ import { createBackgroundCpuYield } from "./background-cpu.js";
11
14
  /** Parallel seals are capped whatever the config asks: sealing is disk- and CPU-bound, and a machine short of space is rarely long on either. */
12
15
  export const MAX_SEAL_CONCURRENCY = 4;
16
+ /** Long enough for a slow local chunk, short enough to expire before the
17
+ * ordinary five-minute queue lease can be mistaken for useful work. */
18
+ export const DEFAULT_SEAL_IDLE_MS = 2 * 60_000;
13
19
  /** Operational summary only: codes and messages, never transcript content. */
14
20
  function summarize(error) {
15
- if (isVaultlineError(error))
21
+ if (isSealkeepError(error))
16
22
  return { code: error.code, message: error.message };
17
23
  return { code: "internal", message: error instanceof Error ? error.message : "Unexpected error" };
18
24
  }
19
- const sha256OfFile = (path) => new Promise((done, failed) => {
20
- const hash = createHash("sha256");
21
- createReadStream(path).on("data", (piece) => hash.update(piece)).on("end", () => done(hash.digest("hex"))).on("error", failed);
22
- });
25
+ const sha256OfFile = async (path, end, onActivity, signal, onYield) => (await hashFileRange(path, 0, end, onActivity, signal, onYield)).sha256;
26
+ /**
27
+ * Keeps one queue lease alive through hashing, compression, encryption and
28
+ * upload. Progress and renewal both rewrite the same file-backed job, so they
29
+ * share one promise lane: otherwise a late progress write could overwrite a
30
+ * newer expiry, or even put `leased` back after `complete` wrote `done`.
31
+ */
32
+ function keepLeaseAlive(queue, jobId, leaseId, options) {
33
+ let stopped = false;
34
+ let writes = Promise.resolve();
35
+ let firstError;
36
+ let lastLeaseWriteAt = Date.now();
37
+ const deadline = createProgressDeadline({
38
+ operation: `Sealing queue job ${jobId}`,
39
+ idleMs: options.idleMs,
40
+ signal: options.signal,
41
+ });
42
+ const schedule = (write) => {
43
+ if (stopped)
44
+ return;
45
+ writes = writes.then(async () => { await write(); }).catch((error) => { firstError ??= error; });
46
+ };
47
+ const renewForActivity = () => {
48
+ deadline.progress();
49
+ const at = Date.now();
50
+ if (at - lastLeaseWriteAt < Math.min(15_000, queue.leaseHeartbeatMs()))
51
+ return;
52
+ lastLeaseWriteAt = at;
53
+ schedule(() => queue.renew(jobId, leaseId));
54
+ };
55
+ return {
56
+ signal: deadline.signal,
57
+ progress(bytes, of) {
58
+ deadline.progress();
59
+ lastLeaseWriteAt = Date.now();
60
+ schedule(() => queue.progress(jobId, leaseId, bytes, of));
61
+ },
62
+ activity: renewForActivity,
63
+ wait(work) { return deadline.wait(work); },
64
+ async stop() {
65
+ if (!stopped) {
66
+ stopped = true;
67
+ deadline.close();
68
+ }
69
+ await writes;
70
+ if (firstError)
71
+ throw firstError;
72
+ }
73
+ };
74
+ }
23
75
  /**
24
76
  * The pressure valve: a transcript the disk cannot hold a sealed copy of
25
77
  * streams STRAIGHT into a chunk folder instead of waiting for a reclaim —
@@ -28,57 +80,60 @@ const sha256OfFile = (path) => new Promise((done, failed) => {
28
80
  * local seal, so the ordinary path (local seal, delta chains, offline
29
81
  * archiving) is untouched; under pressure, full seals beat no seals.
30
82
  */
31
- async function streamUnderPressure(dataDir, job, phrase) {
32
- const config = await readConfig(dataDir);
33
- if ((config.storageMode ?? "sealed") === "plain")
34
- return { reason: "plain vaults keep readable archives on this disk" };
35
- if (config.remoteStorage?.provider === "gdrive")
36
- return { reason: "Google Drive stores archives as single objects, which need local room to build" };
37
- // No remoteStorage entry is NOT "no storage": an enrolled managed vault
38
- // carries a cloud token instead. sealToChunkFolder resolves the target the
39
- // same way the rest of the product does, and its refusal becomes the reason.
40
- // The dedup archiveFile would have done: an unchanged source that is
41
- // already archived completes without sealing — or streaming — anything.
83
+ async function streamUnderPressure(dataDir, job, phrase, options = {}) {
84
+ // Answer the free operation before provider capability. An unchanged source
85
+ // already represented by an archive needs no scratch space and no upload;
86
+ // this is equally true for plain vaults, Drive and an offline managed vault.
87
+ // The former order returned those provider limitations first and left exact,
88
+ // already-verified snapshots stuck in `ready` forever on a full disk.
42
89
  const absolute = resolve(job.sourcePath);
43
90
  const size = await stat(absolute).then((entry) => entry.size).catch(() => -1);
44
91
  const records = await listArchives(dataDir).catch(() => []);
45
92
  const candidates = records.filter(isV2).filter((record) => record.source.path === absolute && record.source.bytes === size);
46
93
  if (candidates.length > 0) {
47
- const digest = await sha256OfFile(absolute).catch(() => null);
94
+ // Hash exactly the snapshot whose length we compared. A resumed/live
95
+ // transcript can keep appending faster than a low-priority reader; an
96
+ // unbounded stream then chases the writer forever and never reaches the
97
+ // actual bounded seal below.
98
+ const digest = await sha256OfFile(absolute, size, options.onActivity, options.signal, options.onYield).catch(() => null);
48
99
  const match = digest ? candidates.find((record) => record.source.sha256 === digest) : undefined;
49
100
  if (match)
50
101
  return { record: match, deduplicated: true };
51
102
  }
103
+ const config = await readConfig(dataDir);
104
+ if ((config.storageMode ?? "sealed") === "plain")
105
+ return { reason: "plain vaults keep readable archives on this disk" };
106
+ if (config.remoteStorage?.provider === "gdrive")
107
+ return { reason: "Google Drive stores archives as single objects, which need local room to build" };
108
+ // No remoteStorage entry is NOT "no storage": an enrolled managed vault
109
+ // carries a cloud token instead. sealToChunkFolder resolves the target the
110
+ // same way the rest of the product does, and its refusal becomes the reason.
52
111
  try {
53
- const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
54
- const project = job.agent === "claude"
55
- ? claudeProjectFromPath(absolute).project
56
- : job.agent === "codex"
57
- ? (await codexProjectFromRollout(absolute, new Date().toISOString())).project
58
- : null;
112
+ const { projectIdentityOfRecord } = await import("./adapters.js");
113
+ const identity = await projectIdentityOfRecord({
114
+ source: { path: absolute, agent: job.agent },
115
+ createdAt: new Date().toISOString(),
116
+ });
117
+ const { project, projectKey } = identity;
59
118
  const { sealToChunkFolder } = await import("./chunk-store.js");
60
- const outcome = await sealToChunkFolder(dataDir, absolute, phrase, job.agent, { project });
119
+ const { teamRecipientsForProject } = await import("./vault.js");
120
+ const teamRecipients = await teamRecipientsForProject(dataDir, project, projectKey);
121
+ const local = await readLocalSettings(dataDir);
122
+ const outcome = await sealToChunkFolder(dataDir, absolute, phrase, job.agent, {
123
+ project, projectKey, teamRecipients, signal: options.signal,
124
+ // Pressure streaming is automatic work on an already stressed machine.
125
+ // Small chunks bound the otherwise unavoidable putObject burst; the
126
+ // sustained rate follows the same per-machine setting as normal uploads.
127
+ chunkBytes: 4 * 1024 * 1024,
128
+ maxBytesPerSecond: backgroundUploadBytesPerSecond(local),
129
+ onYield: options.onYield,
130
+ });
61
131
  return { record: outcome.record, deduplicated: false };
62
132
  }
63
133
  catch (error) {
64
134
  return { reason: summarize(error).message };
65
135
  }
66
136
  }
67
- /**
68
- * A `sealConcurrency` in the vault's config.json, if someone put one there.
69
- * The field is not part of the typed config yet; readConfig parses and keeps
70
- * unknown fields, so a hand-added value is visible here. Anything unreadable
71
- * or non-numeric means the default.
72
- */
73
- async function configuredConcurrency(dataDir) {
74
- try {
75
- const config = (await readConfig(dataDir));
76
- return typeof config.sealConcurrency === "number" ? config.sealConcurrency : undefined;
77
- }
78
- catch {
79
- return undefined;
80
- }
81
- }
82
137
  /**
83
138
  * Encrypts queued transcripts. The recovery phrase lives only in this process:
84
139
  * it is never written to a job, a log line, or the local API.
@@ -97,18 +152,95 @@ export async function drainQueue(dataDir, phrase, options = {}) {
97
152
  const owner = options.owner ?? `${hostname()}:${process.pid}`;
98
153
  const max = options.max ?? 25;
99
154
  const reservations = options.reservations ?? sealReservations;
100
- const requested = options.concurrency ?? (await configuredConcurrency(dataDir)) ?? 1;
155
+ const requested = options.concurrency ?? 1;
101
156
  const concurrency = Math.min(MAX_SEAL_CONCURRENCY, Math.max(1, Math.trunc(requested) || 1));
102
157
  const processed = [];
158
+ // One announcement pass per DRAIN, not per seal: pushing the account's
159
+ // index slot costs the whole slot every time (58 MB on the founder vault,
160
+ // ~60 s), so a two-session drain used to pay it twice. Every fresh seal in
161
+ // an ACTIVE project is collected here and announced once at the end.
162
+ const sealedByProject = new Map();
163
+ // Hooks are separate short-lived processes from the filesystem watcher, and
164
+ // a job may already be ready when someone adds an exclusion. Re-check the
165
+ // same machine policy before a worker claims anything. Held jobs stay ready
166
+ // (nothing is deleted); removing the exclusion makes them eligible again.
167
+ // An explicit selection from Sessions uses event="manual" and intentionally
168
+ // overrides the automatic watcher policy.
169
+ const local = await readLocalSettings(dataDir);
103
170
  const refusedIds = new Set();
171
+ const eligible = (job) => job.event === "manual" || automaticTranscriptIsEnabled(local, job.agent, job.sourcePath);
172
+ const sealIdleMs = Math.max(1, Math.trunc(options.sealIdleMs ?? DEFAULT_SEAL_IDLE_MS));
173
+ const cpuYield = options.backgroundCpuTarget === undefined
174
+ ? undefined
175
+ : createBackgroundCpuYield({
176
+ target: options.backgroundCpuTarget,
177
+ signal: options.signal,
178
+ cpuUsage: options.cpuUsage,
179
+ now: options.now,
180
+ sleep: options.sleep,
181
+ });
104
182
  let budget = max;
105
183
  async function work(workerOwner) {
106
184
  while (budget > 0) {
185
+ if (options.signal?.aborted)
186
+ return;
107
187
  budget -= 1;
108
- const job = await queue.claim(workerOwner, refusedIds);
188
+ const job = await queue.claim(workerOwner, refusedIds, eligible);
109
189
  if (!job?.lease)
110
190
  return;
111
191
  const leaseId = job.lease.id;
192
+ const leaseKeeper = keepLeaseAlive(queue, job.id, leaseId, { signal: options.signal, idleMs: sealIdleMs });
193
+ // Queue metadata describes when intent was recorded; the live path may
194
+ // have grown or compacted since. Reserve and report the bounded state we
195
+ // can actually observe now, not yesterday's stale byte count.
196
+ const sourceSnapshot = await stat(job.sourcePath).then((entry) => ({
197
+ bytes: entry.size,
198
+ modifiedAt: entry.mtime.toISOString(),
199
+ modifiedAtMs: entry.mtimeMs,
200
+ })).catch(() => ({
201
+ bytes: job.sourceBytes,
202
+ modifiedAt: job.sourceModifiedAt,
203
+ modifiedAtMs: Date.parse(job.sourceModifiedAt),
204
+ }));
205
+ // Reclaim leaves a tiny native Codex rollout behind so the vendor's own
206
+ // session catalog can still resolve `codex resume <id>`. It already
207
+ // points at an encrypted archive; sealing that untouched pointer would
208
+ // create a second, content-free memory and make every safety scan churn.
209
+ // Recognition is deliberately exact. The moment Codex appends a turn,
210
+ // the file no longer has the three-line stub shape and is archived as
211
+ // genuinely new work on the normal path below.
212
+ const resumeStub = job.agent === "codex"
213
+ ? await readUntouchedCodexResumeStub(job.sourcePath)
214
+ : null;
215
+ if (resumeStub) {
216
+ const archived = (await listArchives(dataDir)).find((record) => record.version === 2
217
+ && record.id === resumeStub.archiveId && record.source.path === resolve(job.sourcePath)
218
+ && record.reclaimed?.resumeStub?.version === 1
219
+ && record.reclaimed.resumeStub.sessionId === resumeStub.sessionId
220
+ && record.reclaimed.resumeStub.bytes === resumeStub.bytes
221
+ && record.reclaimed.resumeStub.sha256 === resumeStub.sha256);
222
+ if (archived?.version === 2) {
223
+ await leaseKeeper.stop();
224
+ processed.push(await queue.complete(job.id, leaseId, {
225
+ archiveId: archived.id,
226
+ ciphertextSha256: archived.cipher.ciphertextSha256,
227
+ bytes: sourceSnapshot.bytes,
228
+ deduplicated: true,
229
+ }, { bytes: sourceSnapshot.bytes, modifiedAt: sourceSnapshot.modifiedAt }));
230
+ continue;
231
+ }
232
+ }
233
+ const completedSnapshot = async (record) => {
234
+ if (record.source.bytes !== sourceSnapshot.bytes)
235
+ return undefined;
236
+ const after = await stat(job.sourcePath).catch(() => null);
237
+ // If the agent appended or rewrote while sealing, a newer queue row is
238
+ // real work and must survive. Only settle siblings when the exact stat
239
+ // observation used for capacity stayed stable through completion.
240
+ if (!after?.isFile() || after.size !== sourceSnapshot.bytes || after.mtimeMs !== sourceSnapshot.modifiedAtMs)
241
+ return undefined;
242
+ return { bytes: sourceSnapshot.bytes, modifiedAt: sourceSnapshot.modifiedAt };
243
+ };
112
244
  // Do not start an archive there is no room to finish. Sealing writes the
113
245
  // sealed object while the original is still on disk, and the machines
114
246
  // this runs on are, by definition, the ones short of space. The claim is
@@ -117,10 +249,10 @@ export async function drainQueue(dataDir, phrase, options = {}) {
117
249
  // bytes as already spoken for.
118
250
  const guarded = options.requireSpace !== false;
119
251
  if (guarded)
120
- reservations.reserve(job.id, spaceNeededFor(job.sourceBytes, 0));
252
+ reservations.reserve(job.id, spaceNeededFor(sourceSnapshot.bytes, 0));
121
253
  try {
122
254
  if (guarded) {
123
- const room = await roomToArchive(dataDir, job.sourceBytes, options.reserveBytes, reservations, job.id);
255
+ const room = await roomToArchive(dataDir, sourceSnapshot.bytes, options.reserveBytes, reservations, job.id);
124
256
  if (!room.ok) {
125
257
  // Before leaving it queued, try the pressure valve: stream the
126
258
  // seal straight into a chunk folder, whose peak local cost is one
@@ -129,12 +261,17 @@ export async function drainQueue(dataDir, phrase, options = {}) {
129
261
  // size would starve every sibling seal in the pass.
130
262
  if (guarded)
131
263
  reservations.release(job.id);
132
- const streamed = await streamUnderPressure(dataDir, job, phrase);
264
+ const streamed = await leaseKeeper.wait(streamUnderPressure(dataDir, job, phrase, {
265
+ signal: leaseKeeper.signal,
266
+ onActivity: () => leaseKeeper.activity(),
267
+ ...(cpuYield ? { onYield: async () => { leaseKeeper.activity(); await cpuYield(); } } : {}),
268
+ }));
133
269
  if ("record" in streamed) {
270
+ await leaseKeeper.stop();
134
271
  processed.push(await queue.complete(job.id, leaseId, {
135
272
  archiveId: streamed.record.id, ciphertextSha256: streamed.record.cipher.ciphertextSha256,
136
273
  bytes: streamed.record.source.bytes, deduplicated: streamed.deduplicated
137
- }));
274
+ }, await completedSnapshot(streamed.record)));
138
275
  continue;
139
276
  }
140
277
  // Left queued rather than failed: nothing is wrong with the
@@ -143,11 +280,22 @@ export async function drainQueue(dataDir, phrase, options = {}) {
143
280
  // an earlier version stopped the whole loop here, and one 1.7 GB
144
281
  // transcript at the head of a nearly-full disk's queue starved
145
282
  // forty smaller jobs that would have fit.
283
+ await leaseKeeper.stop();
284
+ // Publish the refusal to sibling workers before the row becomes
285
+ // claimable again; otherwise another concurrent worker can take
286
+ // the same oversized row in the release/add scheduling gap.
287
+ refusedIds.add(job.id);
146
288
  processed.push(await queue.release(job.id, leaseId, {
147
289
  code: "insufficient_disk_space",
148
290
  message: `${room.detail} Streaming it to cloud instead was not possible: ${streamed.reason}.`
149
291
  }));
150
- refusedIds.add(job.id);
292
+ // `max` is a completion/attempt budget, not a limit on how many
293
+ // capacity-held rows we may inspect. Give this slot back so one
294
+ // oversized transcript cannot consume the daemon's single-item
295
+ // turn and starve a smaller session behind it. `refusedIds` keeps
296
+ // the pass finite: this row cannot be claimed again until a later
297
+ // drain, after disk or storage conditions may have changed.
298
+ budget += 1;
151
299
  continue;
152
300
  }
153
301
  }
@@ -156,21 +304,65 @@ export async function drainQueue(dataDir, phrase, options = {}) {
156
304
  // job at most every 16 MB or 5% — enough to move visibly on a 1.6 GB
157
305
  // seal, cheap enough to be nothing on a 7 KB one. Per job, so several
158
306
  // concurrent seals each report their own.
159
- const totalBytes = await stat(job.sourcePath).then((s) => s.size).catch(() => 0);
307
+ const totalBytes = sourceSnapshot.bytes;
160
308
  let lastMark = 0;
161
309
  const step = Math.max(16 * 1024 * 1024, Math.floor(totalBytes / 20));
162
310
  const onProgress = totalBytes > 0 ? (bytesRead) => {
311
+ // Every actual source window resets the idle deadline. Queue-file
312
+ // progress remains throttled separately so liveness does not turn
313
+ // a large transcript into thousands of metadata writes.
314
+ leaseKeeper.activity();
163
315
  if (bytesRead - lastMark < step && bytesRead < totalBytes)
164
316
  return;
165
317
  lastMark = bytesRead;
166
- void queue.progress(job.id, leaseId, bytesRead, totalBytes).catch(() => { });
318
+ leaseKeeper.progress(bytesRead, totalBytes);
167
319
  } : undefined;
168
- const record = await archiveFile(dataDir, job.sourcePath, phrase, job.agent, { onProgress, delta: options.delta !== false });
169
- processed.push(await queue.complete(job.id, leaseId, { archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.source.bytes, deduplicated: record.deduplicated }));
320
+ // The project is knowable only while the source is still on disk —
321
+ // derive it NOW and store it in the record, the same answer
322
+ // `projects backfill` would compute later (and cannot, once the
323
+ // source is reclaimed).
324
+ const { projectIdentityOfRecord } = await import("./adapters.js");
325
+ const identity = await projectIdentityOfRecord({ source: { path: job.sourcePath, agent: job.agent }, createdAt: new Date().toISOString() })
326
+ .catch(() => ({ project: null, projectKey: null, projectPath: null }));
327
+ const { project, projectKey } = identity;
328
+ const { teamRecipientsForProject } = await import("./vault.js");
329
+ const teamRecipients = await teamRecipientsForProject(dataDir, project, projectKey);
330
+ // Normal unattended seals use the ranged zstd layout: better
331
+ // compression than gzip while preserving chunk-level search/restore.
332
+ // Pressure streaming stays on its own cloud chunk path and is not
333
+ // changed here because it has a separate bounded-memory protocol.
334
+ const archive = options.archive ?? archiveFile;
335
+ const cooperativeYield = cpuYield
336
+ ? async () => { leaseKeeper.activity(); await cpuYield(); }
337
+ : undefined;
338
+ const record = await leaseKeeper.wait(archive(dataDir, job.sourcePath, phrase, job.agent, {
339
+ onProgress, onActivity: () => leaseKeeper.activity(), delta: options.delta !== false, project, projectKey, teamRecipients,
340
+ compression: "zstd-chunk", chunkBytes: LOCAL_SEAL_CHUNK_BYTES, indexInline: true,
341
+ signal: leaseKeeper.signal,
342
+ ...(cooperativeYield ? { onYield: cooperativeYield } : {}),
343
+ }));
344
+ await leaseKeeper.stop();
345
+ processed.push(await queue.complete(job.id, leaseId, { archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.source.bytes, deduplicated: record.deduplicated }, await completedSnapshot(record)));
346
+ if (!record.deduplicated && record.version === 2 && record.source.project) {
347
+ sealedByProject.set(record.source.projectKey ?? `legacy:${record.source.project}`, {
348
+ project: record.source.project,
349
+ projectKey: record.source.projectKey ?? null,
350
+ sourcePath: job.sourcePath,
351
+ });
352
+ }
170
353
  }
171
354
  catch (error) {
355
+ await leaseKeeper.stop().catch(() => undefined);
172
356
  try {
173
- processed.push(await queue.fail(job.id, leaseId, summarize(error)));
357
+ // A service shutdown is not a failed preservation attempt. The
358
+ // partial is cancelled/removed by archiveFile and the exact row is
359
+ // made immediately claimable by the successor generation.
360
+ if (options.signal?.aborted) {
361
+ processed.push(await queue.release(job.id, leaseId, { code: "cancelled", message: "Sealkeep stopped before this seal completed; it will resume on the next pass." }));
362
+ }
363
+ else {
364
+ processed.push(await queue.fail(job.id, leaseId, summarize(error)));
365
+ }
174
366
  }
175
367
  catch (leaseError) {
176
368
  processed.push({ ...job, lastError: summarize(leaseError) });
@@ -178,6 +370,7 @@ export async function drainQueue(dataDir, phrase, options = {}) {
178
370
  }
179
371
  }
180
372
  finally {
373
+ await leaseKeeper.stop().catch(() => undefined);
181
374
  // Released whatever happened: a completed seal's bytes are now real and
182
375
  // measured by statfs, a refused or failed one never spent them.
183
376
  if (guarded)
@@ -186,5 +379,31 @@ export async function drainQueue(dataDir, phrase, options = {}) {
186
379
  }
187
380
  }
188
381
  await Promise.all(Array.from({ length: concurrency }, (_, index) => work(concurrency > 1 ? `${owner}#${index + 1}` : owner)));
382
+ // ACTIVE-mode projects share at seal speed: the drain's fresh sessions
383
+ // become findable (one incremental index build + sync) and the team feed
384
+ // carries one `finished` line per project. Awaited, so "drain finished"
385
+ // means "announced" — and best-effort by design: a plane hiccup here must
386
+ // never fail seals that already completed.
387
+ if (sealedByProject.size > 0 && (options.announce !== false || options.onActiveSealed)) {
388
+ try {
389
+ const { readConfig, projectSharingOf } = await import("./vault.js");
390
+ const config = await readConfig(dataDir);
391
+ const active = [...sealedByProject.values()].filter(({ project, projectKey }) => projectSharingOf(config, project, projectKey) === "active");
392
+ options.onActiveSealed?.(active);
393
+ if (active.length > 0) {
394
+ if (options.announce !== false) {
395
+ const { buildContentIndex } = await import("./search.js");
396
+ await buildContentIndex(dataDir, phrase, { sync: true });
397
+ const { publishPresence, finishLineFrom } = await import("./presence.js");
398
+ for (const { project, projectKey, sourcePath } of active) {
399
+ await publishPresence(dataDir, { project, projectKey, kind: "finished", line: await finishLineFrom(sourcePath) }, phrase);
400
+ }
401
+ }
402
+ }
403
+ }
404
+ catch (error) {
405
+ process.emitWarning(`active-share skipped: ${error instanceof Error ? error.message : String(error)}`, "SealkeepActiveShare");
406
+ }
407
+ }
189
408
  return processed;
190
409
  }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.8.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/spala-ai/sealkeep.git"
9
+ },
6
10
  "license": "SEE LICENSE IN LICENSE",
7
11
  "engines": {
8
- "node": ">=22.6.0"
12
+ "node": ">=22.15.0"
9
13
  },
10
14
  "os": [
11
15
  "darwin",
@@ -13,12 +17,12 @@
13
17
  "win32"
14
18
  ],
15
19
  "bin": {
16
- "sealkeep": "dist/src/cli.js",
17
- "vaultline": "dist/src/cli.js"
20
+ "sealkeep": "dist/src/cli.js"
18
21
  },
19
22
  "files": [
20
23
  "LICENSE",
21
24
  "dist",
25
+ "scripts/native-reboot-rehearsal.mjs",
22
26
  "web",
23
27
  "README.md",
24
28
  "ARCHITECTURE.md",
@@ -32,28 +36,45 @@
32
36
  "typecheck": "tsc -p tsconfig.check.json",
33
37
  "prepack": "npm run build",
34
38
  "release:manifest": "node scripts/release.mjs",
39
+ "pretest": "node --import tsx test/global-setup.ts",
35
40
  "test": "node --import tsx --test --test-concurrency=4 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
36
- "vaultline": "tsx src/cli.ts",
37
41
  "mcp": "tsx src/mcp.ts",
38
42
  "api": "tsx src/cli.ts api",
39
43
  "control-plane": "tsx src/control-plane-cli.ts",
40
44
  "dashboard": "tsx src/dashboard-cli.ts",
41
- "build:site": "node tools/build-site.mjs",
42
- "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
43
- "acceptance": "node scripts/acceptance.mjs",
45
+ "build:visual": "npm --prefix sealkeep-visual-explanation-solid run build",
46
+ "build:site": "npm run build:visual && node tools/build-site.mjs",
47
+ "bundle:site": "npm run build:site && cd dist/site && zip -q -FS -r -X ../site.zip . && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
48
+ "acceptance": "tsx scripts/acceptance.ts",
44
49
  "acceptance:managed": "node scripts/acceptance.mjs --managed",
45
50
  "tokens": "node scripts/sync-tokens.mjs",
46
51
  "browser:audit": "node scripts/browser/audit.mjs",
47
- "browser:panel": "node scripts/browser/panel.mjs"
52
+ "browser:panel": "node scripts/browser/panel.mjs",
53
+ "rehearsal:company": "node scripts/company-rehearsal.mjs",
54
+ "rehearsal:automatic-memory": "node scripts/automatic-memory-rehearsal.mjs",
55
+ "rehearsal:memory-accuracy": "node --import tsx scripts/memory-accuracy-rehearsal.ts",
56
+ "rehearsal:team-realtime": "npm run build --silent && node --import tsx scripts/team-realtime-rehearsal.ts",
57
+ "rehearsal:hosted-team-realtime": "node --import tsx scripts/hosted-team-realtime-rehearsal.ts",
58
+ "rehearsal:install-upgrade": "node scripts/install-upgrade-rehearsal.mjs",
59
+ "rehearsal:vps-user-journey": "node --import tsx scripts/vps-user-journey-acceptance.ts",
60
+ "rehearsal:reboot-native": "node scripts/native-reboot-rehearsal.mjs",
61
+ "rehearsal:tiers": "node scripts/tier-matrix.mjs",
62
+ "verify": "npm run typecheck && npm test && npm run acceptance",
63
+ "mutation": "node scripts/mutation.mjs",
64
+ "verify:deep": "npm run verify && npm run mutation",
65
+ "soak:restore": "node scripts/soak-restore.mjs",
66
+ "test:ci": "node --import tsx --test --test-concurrency=2 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\""
48
67
  },
49
68
  "devDependencies": {
50
69
  "@types/node": "^22.10.2",
70
+ "socket.io": "^4.8.3",
51
71
  "tsx": "^4.19.2",
52
- "typescript": "^5.7.2",
53
- "zod": "^4.3.6"
72
+ "typescript": "^5.7.2"
54
73
  },
55
74
  "dependencies": {
56
75
  "@modelcontextprotocol/sdk": "^1.30.0",
76
+ "socket.io-client": "^4.8.3",
77
+ "undici": "6.28.0",
57
78
  "zod": "^4.4.3"
58
79
  },
59
80
  "keywords": [
@@ -67,4 +88,4 @@
67
88
  "sessions"
68
89
  ],
69
90
  "homepage": "https://sealkeep.spala.ai"
70
- }
91
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Real native-keystore reboot receipt.
4
+ *
5
+ * PREPARE creates a disposable vault through the shipped CLI, requires the
6
+ * default backend to be native, and proves it can archive without a phrase
7
+ * flag. VERIFY refuses unless the operating-system boot identity changed,
8
+ * then archives again without any secret input. No phrase, token, or key is
9
+ * written to the receipt. CLEANUP removes the test phrase through the public
10
+ * CLI and deletes only the disposable rehearsal directory.
11
+ */
12
+
13
+ import { execFile } from "node:child_process";
14
+ import { randomBytes } from "node:crypto";
15
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { homedir, platform } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const run = promisify(execFile);
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const repo = join(here, "..");
24
+ const bin = process.env.SEALKEEP_REHEARSAL_BIN ?? join(repo, "dist", "src", "cli.js");
25
+ const node = process.execPath;
26
+ const action = process.argv[2] ?? "prepare";
27
+ const root = process.env.SEALKEEP_REBOOT_DIR ?? join(homedir(), ".sealkeep-reboot-rehearsal");
28
+ const dataDir = join(root, "vault");
29
+ const receiptPath = join(root, "receipt.json");
30
+ const expectedBackends = {
31
+ darwin: ["macos-keychain"],
32
+ linux: ["secret-service", "linux-systemd-creds"],
33
+ win32: ["windows-dpapi"],
34
+ }[platform()];
35
+ if (!expectedBackends) throw new Error(`No supported native backend for ${platform()}`);
36
+
37
+ const cleanEnv = { ...process.env };
38
+ delete cleanEnv.SEALKEEP_SECRET_BACKEND;
39
+ delete cleanEnv.VAULTLINE_SECRET_BACKEND;
40
+
41
+ async function bootId() {
42
+ if (platform() === "linux") return (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
43
+ if (platform() === "darwin") return (await run("sysctl", ["-n", "kern.boottime"])).stdout.trim();
44
+ return (await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')"])).stdout.trim();
45
+ }
46
+
47
+ async function cli(args) {
48
+ const result = await run(node, [bin, ...args], { env: cleanEnv, maxBuffer: 4 * 1024 * 1024 });
49
+ return result.stdout.trim();
50
+ }
51
+
52
+ async function archiveCanary(name) {
53
+ const path = join(root, `${name}.jsonl`);
54
+ const marker = `native-reboot-${name}-${randomBytes(12).toString("hex")}`;
55
+ await writeFile(path, JSON.stringify({ type: "user", message: { content: marker } }) + "\n", { mode: 0o600 });
56
+ const output = JSON.parse(await cli(["archive", path, "--agent", "claude", "--data-dir", dataDir, "--json"]));
57
+ if (!output.id) throw new Error(`${name}: archive returned no id`);
58
+ return { id: output.id };
59
+ }
60
+
61
+ if (action === "prepare") {
62
+ await rm(root, { recursive: true, force: true });
63
+ await mkdir(root, { recursive: true, mode: 0o700 });
64
+ const setup = JSON.parse(await cli(["setup", "--data-dir", dataDir, "--json"]));
65
+ if (!expectedBackends.includes(setup.remembered?.backend)) {
66
+ await rm(root, { recursive: true, force: true });
67
+ throw new Error(`Native backend required: expected ${expectedBackends.join(" or ")}, got ${setup.remembered?.backend ?? "none"}`);
68
+ }
69
+ const backend = setup.remembered.backend;
70
+ const before = await archiveCanary("before");
71
+ await writeFile(receiptPath, JSON.stringify({
72
+ version: 1, platform: platform(), backend, bootId: await bootId(),
73
+ preparedAt: new Date().toISOString(), dataDir, beforeArchiveId: before.id,
74
+ }, null, 2) + "\n", { mode: 0o600 });
75
+ process.stdout.write(`PREPARED ${platform()} ${backend}; reboot this host, then run verify. No secret was written to the receipt.\n`);
76
+ } else if (action === "verify") {
77
+ const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
78
+ const currentBoot = await bootId();
79
+ if (currentBoot === receipt.bootId) throw new Error("The OS boot identity did not change; this was not a real reboot");
80
+ const after = await archiveCanary("after");
81
+ await writeFile(receiptPath, JSON.stringify({ ...receipt, verifiedAt: new Date().toISOString(), bootIdAfter: currentBoot, afterArchiveId: after.id, passed: true }, null, 2) + "\n", { mode: 0o600 });
82
+ if (!expectedBackends.includes(receipt.backend)) throw new Error(`Receipt backend ${receipt.backend} is not native on ${platform()}`);
83
+ process.stdout.write(`PASS ${platform()} ${receipt.backend}: native secret survived a real reboot and sealed new work without re-entry.\n`);
84
+ } else if (action === "cleanup") {
85
+ await cli(["recovery", "forget-machine", "--confirm", "--data-dir", dataDir, "--json"]).catch(() => "");
86
+ await rm(root, { recursive: true, force: true });
87
+ process.stdout.write(`CLEANED ${root}; encrypted production vaults and integrations were not touched.\n`);
88
+ } else {
89
+ throw new Error("Usage: native-reboot-rehearsal.mjs prepare|verify|cleanup");
90
+ }