sealkeep 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -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 +1698 -163
  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.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,10 +1,14 @@
1
- import { mkdir, rename, rm, stat } from "node:fs/promises";
1
+ import { lstat, mkdir, realpath, rm, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve, sep } from "node:path";
5
- import { fail } from "./errors.js";
5
+ import { fail, isSealkeepError } from "./errors.js";
6
6
  import { recordAudit } from "./audit.js";
7
7
  import { assertPhraseOpens, findArchive, readConfig, restoreRecordToFile } from "./vault.js";
8
+ import { hashFileRange } from "../packages/sealkeep-crypto/src/index.js";
9
+ import { readSourceBytes, resolveSourceReference } from "./source-reader.js";
10
+ import { LEGACY_BUFFER_LIMIT_BYTES } from "./byte-stream.js";
11
+ import { parkExistingPathNoClobber, publishTempCreateIfAbsent, restoreParkedCreateIfAbsent, validateNoFollowPath, } from "./reclaim-transaction.js";
8
12
  /**
9
13
  * Where each adapter's transcripts legitimately live. Restoring "natively" means
10
14
  * putting bytes back at the recorded original path, and only if that path is still
@@ -21,9 +25,24 @@ function within(candidate, root) {
21
25
  }
22
26
  /** Resolves the adapter-native destination, refusing anything outside the agent's roots. */
23
27
  export function nativeRestoreTarget(record, home = homedir()) {
24
- const agent = record.source.agent;
28
+ // A record's agent tag is sticky: archive a Codex session once without
29
+ // naming the agent and it is "custom" forever, because re-archiving the same
30
+ // bytes returns the original record rather than writing a new one. The
31
+ // recorded path still says plainly where the file came from, so when the tag
32
+ // is unhelpful, ask the path. This only ever RESOLVES to a real agent root —
33
+ // the containment check below still refuses anything outside one.
34
+ let agent = record.source.agent;
25
35
  if (agent !== "codex" && agent !== "claude") {
26
- fail("native_restore_unavailable", `No native restore path is defined for agent "${agent}"; pass an explicit destination`, { agent });
36
+ const resolved = resolve(record.source.path);
37
+ for (const candidate of ["codex", "claude"]) {
38
+ if (NATIVE_ROOTS[candidate](home).some((root) => within(resolved, root))) {
39
+ agent = candidate;
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ if (agent !== "codex" && agent !== "claude") {
45
+ fail("native_restore_unavailable", `No native restore path is defined for agent "${record.source.agent}", and ${record.source.path} is not inside a known agent directory; pass an explicit destination`, { agent: record.source.agent });
27
46
  }
28
47
  const roots = NATIVE_ROOTS[agent](home);
29
48
  const original = resolve(record.source.path);
@@ -32,56 +51,273 @@ export function nativeRestoreTarget(record, home = homedir()) {
32
51
  }
33
52
  return original;
34
53
  }
54
+ /**
55
+ * Build and verify a native transcript parent without following a symlinked
56
+ * `.codex`, `.claude`, sessions root, or date/project directory. The lexical
57
+ * target still remains the adapter's recorded path; canonicalisation here
58
+ * only makes platform aliases such as macOS `/var` -> `/private/var` safe to
59
+ * compare.
60
+ */
61
+ export async function validateNativeRestoreOutput(output, home = homedir(), leaf = "missing-or-file") {
62
+ const lexicalHome = resolve(home);
63
+ const canonicalHome = await realpath(lexicalHome);
64
+ const suffix = output === lexicalHome ? "" : output.startsWith(lexicalHome.endsWith(sep) ? lexicalHome : `${lexicalHome}${sep}`)
65
+ ? output.slice(lexicalHome.length + (lexicalHome.endsWith(sep) ? 0 : 1))
66
+ : null;
67
+ if (suffix === null || suffix === "")
68
+ fail("path_not_permitted", `Native transcript path is outside the selected home: ${output}`);
69
+ const canonicalOutput = join(canonicalHome, suffix);
70
+ const roots = [...NATIVE_ROOTS.codex(canonicalHome), ...NATIVE_ROOTS.claude(canonicalHome)];
71
+ const parent = dirname(canonicalOutput);
72
+ const root = roots.find((candidate) => within(parent, candidate));
73
+ if (!root)
74
+ fail("path_not_permitted", `Native transcript path is outside the agent roots: ${output}`);
75
+ let cursor = canonicalHome;
76
+ for (const part of parent.slice(canonicalHome.length).split(sep).filter(Boolean)) {
77
+ cursor = join(cursor, part);
78
+ await mkdir(cursor, { mode: 0o700 }).catch((error) => {
79
+ if (error.code !== "EEXIST")
80
+ throw error;
81
+ });
82
+ const info = await lstat(cursor);
83
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(cursor) !== cursor) {
84
+ fail("path_not_permitted", `Native transcript parent is not a canonical directory: ${cursor}`);
85
+ }
86
+ }
87
+ try {
88
+ await validateNoFollowPath(canonicalOutput, roots, { leaf });
89
+ }
90
+ catch {
91
+ fail("path_not_permitted", `Native transcript path is not a regular file under its agent root: ${output}`);
92
+ }
93
+ }
94
+ function errorCode(error) {
95
+ return typeof error === "object" && error !== null && "code" in error
96
+ ? String(error.code)
97
+ : undefined;
98
+ }
99
+ function destinationConflict(output) {
100
+ fail("destination_exists", `${output} already exists or changed while recovery was finishing. Its bytes were kept. Re-run after the writer stops, or choose another destination.`, { destination: output });
101
+ }
102
+ async function expectedFileOrNull(path) {
103
+ try {
104
+ return await hashFileRange(path);
105
+ }
106
+ catch (error) {
107
+ if (errorCode(error) === "ENOENT")
108
+ return null;
109
+ throw error;
110
+ }
111
+ }
112
+ async function publishRestoreNoClobber(temp, output, expected) {
113
+ try {
114
+ await publishTempCreateIfAbsent(temp, output, expected);
115
+ }
116
+ catch (error) {
117
+ if (errorCode(error) === "EEXIST")
118
+ destinationConflict(output);
119
+ throw error;
120
+ }
121
+ }
122
+ async function commitRestore(temp, output, expected, policy, operations) {
123
+ await operations?.beforeCommit?.({ output, policy });
124
+ if (policy === "refuse") {
125
+ await publishRestoreNoClobber(temp, output, expected);
126
+ return {};
127
+ }
128
+ const displaced = await expectedFileOrNull(output);
129
+ if (!displaced) {
130
+ await publishRestoreNoClobber(temp, output, expected);
131
+ return {};
132
+ }
133
+ // `replace` changes the active path, but it cannot safely unlink the old
134
+ // inode: another process may still have it open and append after any final
135
+ // stat/hash check. Keep that inode at a visible safety path, just as backup
136
+ // does, so every concurrent byte remains addressable.
137
+ const tag = policy === "backup" ? "backup" : "replaced";
138
+ const parkPath = `${output}.sealkeep-${tag}-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`;
139
+ await operations?.beforePark?.({ output, parkPath, policy });
140
+ try {
141
+ await parkExistingPathNoClobber(output, parkPath, displaced);
142
+ }
143
+ catch (error) {
144
+ if (errorCode(error) === "EEXIST") {
145
+ fail("destination_exists", `Recovery could not reserve ${parkPath}. Both that file and the current destination were kept; retry creates a different safety path.`, { destination: output, preservedPath: parkPath });
146
+ }
147
+ throw error;
148
+ }
149
+ await operations?.afterPark?.({ output, parkPath, policy });
150
+ try {
151
+ await publishRestoreNoClobber(temp, output, expected);
152
+ }
153
+ catch (error) {
154
+ // If nobody claimed the destination, put the displaced inode back. If a
155
+ // contender did, both it and the parked inode remain visible and untouched.
156
+ const rollback = await restoreParkedCreateIfAbsent(parkPath, output, displaced)
157
+ .then(() => "restored", () => "preserved");
158
+ if (rollback === "preserved") {
159
+ fail("destination_exists", `Recovery could not safely publish at ${output}. Any competing file was kept there, and the displaced file was kept at ${parkPath}.`, { destination: output, preservedPath: parkPath });
160
+ }
161
+ throw error;
162
+ }
163
+ return { backupPath: parkPath };
164
+ }
35
165
  /**
36
166
  * Restores one archive atomically.
37
167
  *
38
- * The plaintext is streamed to a temporary file beside the destination and renamed
39
- * into place, so a crash never leaves a half-written transcript — and a transcript
168
+ * The plaintext is streamed to a temporary file beside the destination and
169
+ * published with create-if-absent semantics, so a crash never leaves a
170
+ * half-written transcript — and a transcript
40
171
  * of any size is restored without ever holding it in memory. An existing file
41
- * is refused by default; `backup` preserves it beside the restored copy and
42
- * `replace` is the only policy that discards it.
172
+ * is refused by default. `backup` preserves the displaced inode beside the
173
+ * restored copy. `replace` replaces the active destination too, but retains
174
+ * the displaced inode under a visible `.sealkeep-replaced-*` safety path: no
175
+ * portable filesystem primitive can prove that another process has stopped
176
+ * writing through an already-open descriptor.
43
177
  */
44
178
  export async function restoreArchive(dataDir, id, phrase, options = {}) {
45
179
  // A recipient key opens an archive it was wrapped for, without the phrase.
46
180
  // That is how an organisation reads what someone who has left sealed, and
47
181
  // how a project member reads a project they were added to.
48
182
  const key = options.privateKey;
49
- const record = await findArchive(dataDir, id);
50
183
  const native = options.native === true;
51
184
  if (native && options.destination)
52
185
  fail("invalid_argument", "Choose either --native or an explicit destination, not both");
53
186
  if (!native && !options.destination)
54
187
  fail("invalid_argument", "A destination is required unless --native is used");
55
- const output = native ? nativeRestoreTarget(record, options.home ?? homedir()) : resolve(options.destination);
56
- const policy = options.overwrite ?? "refuse";
57
- const existing = await stat(output).then(() => true).catch(() => false);
58
- if (existing && policy === "refuse") {
59
- fail("destination_exists", `${output} already exists. Re-run with --overwrite backup to keep it, or --overwrite replace to discard it.`, { destination: output });
60
- }
61
- // Proved before anything is created, because the restore itself writes as it
62
- // decrypts: a wrong phrase must not leave a directory or a partial file
63
- // behind. The config decides what "proved" means — unwrapping for a sealed
64
- // vault, the identity check for a plain one.
65
- // The phrase check proves the PHRASE opens this vault; a key holder has no
66
- // phrase and the wrap itself is the proof, so the unwrap below is the gate.
67
- if (!key)
68
- assertPhraseOpens(await readConfig(dataDir), record, phrase);
69
- await mkdir(dirname(output), { recursive: true });
70
- const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
71
- let backupPath;
72
- let bytes;
188
+ const source = options.source ?? (options.project ? await resolveSourceReference(dataDir, phrase, id, options.project, options.projectKey) : undefined);
189
+ if (options.source && id !== options.source.archiveId)
190
+ fail("invalid_argument", "archiveId must match the immutable source reference");
191
+ if (source?.spaceKey && native)
192
+ fail("native_restore_unavailable", "A Team source must be restored to an explicit destination on this device");
193
+ if (source?.spaceKey && options.destination && (options.overwrite ?? "refuse") === "refuse"
194
+ && await lstat(resolve(options.destination)).then(() => true, () => false))
195
+ destinationConflict(resolve(options.destination));
196
+ const teamRead = source ? await readSourceBytes(dataDir, phrase, source, {
197
+ maxWireBytes: LEGACY_BUFFER_LIMIT_BYTES, maxDecodedBytes: LEGACY_BUFFER_LIMIT_BYTES,
198
+ signal: AbortSignal.timeout(120_000),
199
+ }) : undefined;
73
200
  try {
74
- ({ bytes } = await restoreRecordToFile(dataDir, record, key ? { privateKey: key } : phrase, temp));
75
- if (existing && policy === "backup") {
76
- backupPath = `${output}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
77
- await rename(output, backupPath);
201
+ const record = teamRead?.record ?? await findArchive(dataDir, id);
202
+ const output = native ? nativeRestoreTarget(record, options.home ?? homedir()) : resolve(options.destination);
203
+ const policy = options.overwrite ?? "refuse";
204
+ const existing = await lstat(output).then(() => true).catch(() => false);
205
+ // A dry run answers "what would this restore do" and stops before anything
206
+ // is created: no directory, no temp file, no audit row. The phrase (or the
207
+ // team read above) is still proven, so a wrong phrase fails here too instead
208
+ // of appearing to preview successfully. An existing destination is reported,
209
+ // not thrown — the caller asked what would happen, and this is the answer.
210
+ if (options.dryRun) {
211
+ if (!key && !teamRead)
212
+ assertPhraseOpens(await readConfig(dataDir), record, phrase);
213
+ return { record, output, bytes: record.source.bytes, native, dryRun: true, destinationExists: existing };
214
+ }
215
+ if (existing && policy === "refuse") {
216
+ fail("destination_exists", `${output} already exists. Re-run with --overwrite backup or replace; either keeps the displaced file at a visible safety path.`, { destination: output });
217
+ }
218
+ // Proved before anything is created, because the restore itself writes as it
219
+ // decrypts: a wrong phrase must not leave a directory or a partial file
220
+ // behind. The config decides what "proved" means — unwrapping for a sealed
221
+ // vault, the identity check for a plain one.
222
+ // The phrase check proves the PHRASE opens this vault; a key holder has no
223
+ // phrase and the wrap itself is the proof, so the unwrap below is the gate.
224
+ if (!key && !teamRead)
225
+ assertPhraseOpens(await readConfig(dataDir), record, phrase);
226
+ if (native)
227
+ await validateNativeRestoreOutput(output, options.home ?? homedir(), "missing-or-file");
228
+ else
229
+ await mkdir(dirname(output), { recursive: true });
230
+ const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
231
+ let backupPath;
232
+ let bytes;
233
+ try {
234
+ if (teamRead) {
235
+ await writeFile(temp, teamRead.bytes, { mode: 0o600, flag: "wx" });
236
+ bytes = teamRead.bytes.length;
237
+ await teamRead.assertCurrent();
238
+ }
239
+ else
240
+ ({ bytes } = await restoreRecordToFile(dataDir, record, key ? { privateKey: key } : phrase, temp));
241
+ const committed = await commitRestore(temp, output, { bytes, sha256: record.source.sha256 }, policy, teamRead ? { ...options.operations, beforeCommit: async (input) => {
242
+ await options.operations?.beforeCommit?.(input);
243
+ await teamRead.assertCurrent();
244
+ } } : options.operations);
245
+ backupPath = committed.backupPath;
246
+ }
247
+ catch (error) {
248
+ await rm(temp, { force: true });
249
+ // Refusals are what an audit log is FOR: a revoked colleague probing an
250
+ // archive after removal used to leave no trace at all.
251
+ await recordAudit(dataDir, "archive.restore", "denied", { archiveId: id, output, reason: isSealkeepError(error) ? error.code : "error" }).catch(() => undefined);
252
+ throw error;
78
253
  }
79
- await rename(temp, output);
254
+ finally {
255
+ teamRead?.bytes.fill(0);
256
+ }
257
+ await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: id, output, bytes, native, backedUp: Boolean(backupPath) });
258
+ return { record, output, bytes, native, backupPath };
259
+ }
260
+ finally {
261
+ teamRead?.bytes.fill(0);
262
+ }
263
+ }
264
+ /**
265
+ * Proves an archive can produce the bytes it claims, before anything is taken.
266
+ *
267
+ * Decrypts the archive to a scratch file and hashes the result. Nothing else
268
+ * in this file is allowed to trash a source until this has returned true —
269
+ * the point is to catch a codec or storage fault while the original is still
270
+ * on disk, which is the only moment catching it is worth anything.
271
+ */
272
+ export async function verifyArchiveReadsBack(dataDir, archiveId, phrase, expectedSha256) {
273
+ const { createHash } = await import("node:crypto");
274
+ const { Writable } = await import("node:stream");
275
+ const { findArchive, restoreRecordToFile } = await import("./vault.js");
276
+ /**
277
+ * Hash the plaintext as it goes past; write it nowhere.
278
+ *
279
+ * This used to restore to a scratch file and hash that, which cost as much
280
+ * FREE DISK as the original session — so on a full disk the proof was
281
+ * impossible exactly when it was needed, and `squeeze` reported the ENOSPC as
282
+ * "the archive did not read back", which reads as corruption. The bytes are
283
+ * still hashed and still compared; they simply never land.
284
+ */
285
+ const digest = createHash("sha256");
286
+ const sink = new Writable({ write(chunk, _enc, done) { digest.update(chunk); done(); } });
287
+ try {
288
+ const record = await findArchive(dataDir, archiveId);
289
+ await restoreRecordToFile(dataDir, record, phrase, sink);
290
+ return digest.digest("hex") === expectedSha256;
80
291
  }
81
292
  catch (error) {
82
- await rm(temp, { force: true });
83
- throw error;
293
+ // A delta chain or a plain-mode vault rebuilds through real files. Those
294
+ // fall back to the scratch-file proof, which is correct where there is room.
295
+ const { isSealkeepError } = await import("./errors.js");
296
+ if (isSealkeepError(error) && error.code === "invalid_argument")
297
+ return verifyViaScratchFile(dataDir, archiveId, phrase, expectedSha256);
298
+ return false;
299
+ }
300
+ }
301
+ /** The original proof: restore to a scratch file and hash it. Needs room for the whole session. */
302
+ async function verifyViaScratchFile(dataDir, archiveId, phrase, expectedSha256) {
303
+ const { mkdtemp, rm: removeFile } = await import("node:fs/promises");
304
+ const { tmpdir } = await import("node:os");
305
+ const { join: joinPath } = await import("node:path");
306
+ const { createReadStream } = await import("node:fs");
307
+ const { createHash } = await import("node:crypto");
308
+ const { pipeline } = await import("node:stream/promises");
309
+ const scratch = await mkdtemp(joinPath(tmpdir(), "sealkeep-verify-"));
310
+ const probe = joinPath(scratch, "readback");
311
+ try {
312
+ await restoreArchive(dataDir, archiveId, phrase, { destination: probe, overwrite: "replace" });
313
+ const digest = createHash("sha256");
314
+ await pipeline(createReadStream(probe), digest);
315
+ return digest.digest("hex") === expectedSha256;
316
+ }
317
+ catch {
318
+ return false;
319
+ }
320
+ finally {
321
+ await removeFile(scratch, { recursive: true, force: true });
84
322
  }
85
- await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: id, output, bytes, native, backedUp: Boolean(backupPath) });
86
- return { record, output, bytes, native, backupPath };
87
323
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A stable, model-visible marker. It deliberately lives in an ordinary Codex
3
+ * message rather than in a made-up rollout record type: old and new Codex
4
+ * builds may ignore fields they do not know, but the conversation loader has
5
+ * always understood user/assistant messages.
6
+ */
7
+ export declare const CODEX_RESUME_STUB_MARKER = "sealkeep://codex-resume-stub/v1";
8
+ export type CodexResumeStub = {
9
+ version: 1;
10
+ archiveId: string;
11
+ sessionId: string;
12
+ path: string;
13
+ bytes: number;
14
+ sha256: string;
15
+ };
16
+ export type PreparedCodexResumeStub = CodexResumeStub & {
17
+ tempPath: string;
18
+ atime: Date;
19
+ mtime: Date;
20
+ };
21
+ /**
22
+ * Only real, non-symlinked native Codex rollout locations participate.
23
+ *
24
+ * Lexical containment is insufficient here: a symlinked `sessions` directory
25
+ * can make a path spell `~/.codex/sessions/...` while naming an unrelated file
26
+ * elsewhere. Check every user-controlled path component with lstat, then check
27
+ * canonical containment as a second independent condition.
28
+ */
29
+ export declare function isNativeCodexSessionPath(path: string, home?: string): Promise<boolean>;
30
+ /** Canonical no-follow validation shared by reclaim and crash reconciliation. */
31
+ export declare function validateNativeCodexSessionPath(path: string, home?: string, leaf?: "file" | "missing-or-file"): Promise<string>;
32
+ /** The native filename, not a parent directory or a longer hex token, owns the session id. */
33
+ export declare function codexSessionIdMatchesFilename(path: string, sessionId: string): boolean;
34
+ /** Validates that replacing this exact native rollout with a pointer is supported. */
35
+ export declare function assertCodexResumePointerSupported(path: string, home?: string): Promise<void>;
36
+ /**
37
+ * Re-validates a staged original against its logical native filename.
38
+ * Crash reconciliation uses this after upgrades: staging lives inside the
39
+ * vault, but the rollout UUID contract still belongs to the original path.
40
+ */
41
+ export declare function assertCodexResumePointerMetadataSupported(contentsPath: string, logicalNativePath: string): Promise<void>;
42
+ /**
43
+ * A reclaim journal may outlive the binary that wrote it. Do not let such a
44
+ * journal turn an arbitrary neighbouring file into a replacement temp: only
45
+ * the private basename generated by prepareCodexResumeStub is eligible.
46
+ */
47
+ export declare function assertCodexResumePointerTempPath(tempPath: string): void;
48
+ /**
49
+ * Return true only when an existing pathname is the exact journaled pointer
50
+ * and its structured contents bind it to this native session and archive.
51
+ * A missing or fingerprint-mismatched pathname returns false because generic
52
+ * reconciliation already retains it. A matching-but-invalid pathname throws:
53
+ * deleting or publishing it based only on a journal-supplied hash would let a
54
+ * corrupt journal target an unrelated neighbouring file.
55
+ */
56
+ export declare function validateCodexResumePointerBinding(contentsPath: string, logicalNativePath: string, archiveId: string, expected: {
57
+ bytes: number;
58
+ sha256: string;
59
+ }): Promise<boolean>;
60
+ /** Own and validated parent IDs that may identify a live Codex process. */
61
+ export declare function codexSessionProcessIds(path: string): Promise<string[]>;
62
+ /** IDs plus project cwd used when a live Codex process has no UUID in argv. */
63
+ export declare function codexSessionProcessIdentity(path: string): Promise<{
64
+ ids: string[];
65
+ cwd: string;
66
+ }>;
67
+ /**
68
+ * Builds the replacement beside the source, while the original is still
69
+ * present and fully verified. The caller transaction-stages the original first
70
+ * and then publishes this prepared file into its native path without
71
+ * overwriting a path the agent recreated in the meantime.
72
+ *
73
+ * The original session-meta line is kept byte-for-byte. It is the format
74
+ * contract of the installed Codex build and carries the stable id, cwd, git
75
+ * project, source kind and original timestamp. Even a large modern metadata
76
+ * line is tiny beside a multi-GB rollout and avoids guessing at vendor fields.
77
+ */
78
+ export declare function prepareCodexResumeStub(sourcePath: string, archiveId: string, options?: {
79
+ home?: string;
80
+ project?: string | null;
81
+ /** Test seam; production fsyncs the complete pointer inode after utimes. */
82
+ syncPreparedFile?: (path: string) => Promise<void>;
83
+ }): Promise<PreparedCodexResumeStub | null>;
84
+ /** Installs an already-prepared pointer without clobbering a recreated path. */
85
+ export declare function installCodexResumeStub(stub: PreparedCodexResumeStub): Promise<void>;
86
+ export declare function discardCodexResumeStub(stub: PreparedCodexResumeStub | null): Promise<void>;
87
+ /**
88
+ * Recognises only the complete, untouched pointer format. Once Codex appends
89
+ * a new turn there are more than the three expected lines, so the worker
90
+ * archives that new work normally instead of permanently excluding the file.
91
+ */
92
+ export declare function readUntouchedCodexResumeStub(path: string): Promise<CodexResumeStub | null>;