sealkeep 0.5.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 (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,112 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ /**
3
+ * AWS Signature Version 4 for S3-compatible endpoints (S3, R2, MinIO).
4
+ *
5
+ * Implemented from the published specification and checked against AWS's own
6
+ * documented example in `test/sigv4.test.ts`, including the intermediate canonical
7
+ * request and string-to-sign, so a regression is caught at the step that broke.
8
+ */
9
+ export const ALGORITHM = "AWS4-HMAC-SHA256";
10
+ export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
11
+ const hex = (value) => (typeof value === "string" ? Buffer.from(value) : value).toString("hex");
12
+ const sha256hex = (value) => createHash("sha256").update(value).digest("hex");
13
+ const hmac = (key, value) => createHmac("sha256", key).update(value, "utf8").digest();
14
+ /** RFC 3986 unreserved characters stay literal; everything else is percent-encoded. */
15
+ export function uriEncode(value, encodeSlash = true) {
16
+ let output = "";
17
+ for (const byte of Buffer.from(value, "utf8")) {
18
+ const char = String.fromCharCode(byte);
19
+ if (/[A-Za-z0-9\-_.~]/.test(char))
20
+ output += char;
21
+ else if (char === "/" && !encodeSlash)
22
+ output += char;
23
+ else
24
+ output += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
25
+ }
26
+ return output;
27
+ }
28
+ export function canonicalUri(path) {
29
+ if (!path || path === "/")
30
+ return "/";
31
+ return uriEncode(path.startsWith("/") ? path : `/${path}`, false);
32
+ }
33
+ export function canonicalQuery(query) {
34
+ return [...query]
35
+ .map(([key, value]) => [uriEncode(key), uriEncode(value)])
36
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
37
+ .map(([key, value]) => `${key}=${value}`)
38
+ .join("&");
39
+ }
40
+ export function canonicalHeaders(headers) {
41
+ const entries = Object.entries(headers)
42
+ .map(([name, value]) => [name.toLowerCase().trim(), value.trim().replace(/\s+/g, " ")])
43
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
44
+ return { canonical: entries.map(([name, value]) => `${name}:${value}\n`).join(""), signed: entries.map(([name]) => name).join(";") };
45
+ }
46
+ export function canonicalRequest(input) {
47
+ const { canonical, signed } = canonicalHeaders(input.headers);
48
+ return {
49
+ request: [input.method.toUpperCase(), canonicalUri(input.path), canonicalQuery(input.query), canonical, signed, input.payloadHash].join("\n"),
50
+ signedHeaders: signed
51
+ };
52
+ }
53
+ export function credentialScope(date, region, service) {
54
+ return `${date}/${region}/${service}/aws4_request`;
55
+ }
56
+ export function stringToSign(amzDate, scope, request) {
57
+ return [ALGORITHM, amzDate, scope, sha256hex(request)].join("\n");
58
+ }
59
+ export function signingKey(secretAccessKey, date, region, service) {
60
+ return hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, date), region), service), "aws4_request");
61
+ }
62
+ export function sign(secretAccessKey, date, region, service, toSign) {
63
+ return hex(hmac(signingKey(secretAccessKey, date, region, service), toSign));
64
+ }
65
+ /** `20130524T000000Z` and its `20130524` date portion. */
66
+ export function amzDates(when) {
67
+ const amzDate = `${when.toISOString().replace(/[:-]|\.\d{3}/g, "")}`;
68
+ return { amzDate, date: amzDate.slice(0, 8) };
69
+ }
70
+ /**
71
+ * Produces a presigned URL. Host is always signed; any extra header supplied here
72
+ * is signed too and must be sent verbatim by the uploader or the request is rejected.
73
+ */
74
+ export function presign(input) {
75
+ const service = input.service ?? "s3";
76
+ const when = input.when ?? new Date();
77
+ const { amzDate, date } = amzDates(when);
78
+ const expires = input.expiresInSeconds ?? 900;
79
+ const scope = credentialScope(date, input.region, service);
80
+ const headers = { host: input.port ? `${input.host}:${input.port}` : input.host, ...input.headers };
81
+ const query = new Map(Object.entries(input.query ?? {}));
82
+ query.set("X-Amz-Algorithm", ALGORITHM);
83
+ query.set("X-Amz-Credential", `${input.credentials.accessKeyId}/${scope}`);
84
+ query.set("X-Amz-Date", amzDate);
85
+ query.set("X-Amz-Expires", String(expires));
86
+ const { canonical: _c, signed } = canonicalHeaders(headers);
87
+ query.set("X-Amz-SignedHeaders", signed);
88
+ if (input.credentials.sessionToken)
89
+ query.set("X-Amz-Security-Token", input.credentials.sessionToken);
90
+ const { request } = canonicalRequest({ method: input.method, path: input.path, query, headers, payloadHash: input.payloadHash ?? UNSIGNED_PAYLOAD });
91
+ const toSign = stringToSign(amzDate, scope, request);
92
+ const signature = sign(input.credentials.secretAccessKey, date, input.region, service, toSign);
93
+ const protocol = input.protocol ?? "https";
94
+ const authority = input.port ? `${input.host}:${input.port}` : input.host;
95
+ const url = `${protocol}://${authority}${canonicalUri(input.path)}?${canonicalQuery(query)}&X-Amz-Signature=${signature}`;
96
+ return { url, signature, canonicalRequest: request, stringToSign: toSign, signedHeaders: signed, expiresAt: new Date(when.getTime() + expires * 1000).toISOString() };
97
+ }
98
+ /** Recomputes a presigned signature so a server can verify it. Used by the test emulator. */
99
+ export function verifyPresigned(input) {
100
+ const query = new Map();
101
+ for (const [key, value] of input.url.searchParams)
102
+ if (key !== "X-Amz-Signature")
103
+ query.set(key, value);
104
+ const presented = input.url.searchParams.get("X-Amz-Signature");
105
+ const amzDate = query.get("X-Amz-Date");
106
+ if (!presented || !amzDate)
107
+ return false;
108
+ const headers = { host: input.url.host, ...input.extraHeaders };
109
+ const { request } = canonicalRequest({ method: input.method, path: decodeURIComponent(input.url.pathname), query, headers, payloadHash: UNSIGNED_PAYLOAD });
110
+ const expected = sign(input.credentials.secretAccessKey, amzDate.slice(0, 8), input.region, input.service ?? "s3", stringToSign(amzDate, credentialScope(amzDate.slice(0, 8), input.region, input.service ?? "s3"), request));
111
+ return expected === presented;
112
+ }
@@ -0,0 +1,185 @@
1
+ export type JobStatus = "ready" | "leased" | "done" | "failed";
2
+ export type ArchiveJob = {
3
+ version: 1;
4
+ /** Content-addressed: agent + resolved source path + byte length + mtime. */
5
+ id: string;
6
+ status: JobStatus;
7
+ agent: string;
8
+ sourcePath: string;
9
+ /** The lifecycle event that first created this job. Duplicates never rewrite it. */
10
+ event: string;
11
+ sessionId?: string;
12
+ sourceBytes: number;
13
+ sourceModifiedAt: string;
14
+ enqueuedAt: string;
15
+ updatedAt: string;
16
+ attempts: number;
17
+ nextAttemptAt: string;
18
+ lastError?: {
19
+ code: string;
20
+ message: string;
21
+ };
22
+ /** Live seal progress while leased: bytes of the source read so far, of its total. Written throttled by the worker; the queue view turns it into a percentage. */
23
+ progress?: {
24
+ bytes: number;
25
+ of: number;
26
+ };
27
+ lease?: {
28
+ id: string;
29
+ owner: string;
30
+ acquiredAt: string;
31
+ expiresAt: string;
32
+ };
33
+ result?: {
34
+ archiveId: string;
35
+ ciphertextSha256: string;
36
+ bytes: number;
37
+ deduplicated?: boolean;
38
+ completedAt: string;
39
+ };
40
+ /** Set instead of `result` when a later snapshot of the same transcript took this job's place. */
41
+ supersededBy?: string;
42
+ };
43
+ export type EnqueueRequest = {
44
+ sourcePath: string;
45
+ agent: string;
46
+ event: string;
47
+ sessionId?: string;
48
+ };
49
+ export type QueueOptions = {
50
+ now?: () => number;
51
+ leaseMs?: number;
52
+ maxAttempts?: number;
53
+ backoffMs?: number;
54
+ maxBackoffMs?: number;
55
+ };
56
+ export declare function queueDir(dataDir: string): string;
57
+ /**
58
+ * Two lifecycle events describing the same unchanged transcript produce the same
59
+ * key, so a `PostCompact` followed by a `SessionEnd` archives once.
60
+ */
61
+ export declare function jobId(input: {
62
+ agent: string;
63
+ sourcePath: string;
64
+ bytes: number;
65
+ modifiedAtMs: number;
66
+ }): string;
67
+ /**
68
+ * A restart-safe, file-backed work queue. Each job is one atomically written
69
+ * file named by its content-addressed id, so concurrent hook processes cannot
70
+ * create duplicates and an interrupted worker leaves recoverable state.
71
+ */
72
+ export declare class ArchiveQueue {
73
+ private readonly root;
74
+ private readonly now;
75
+ private readonly leaseMs;
76
+ private readonly maxAttempts;
77
+ private readonly backoffMs;
78
+ private readonly maxBackoffMs;
79
+ constructor(dataDir: string, options?: QueueOptions);
80
+ private path;
81
+ private stamp;
82
+ private writeAtomic;
83
+ /** Creates the file only if the id is unseen; the content is complete before it becomes visible. */
84
+ private createExclusive;
85
+ get(id: string): Promise<ArchiveJob | null>;
86
+ private require;
87
+ list(filter?: {
88
+ status?: JobStatus;
89
+ }): Promise<ArchiveJob[]>;
90
+ stats(): Promise<{
91
+ total: number;
92
+ ready: number;
93
+ leased: number;
94
+ done: number;
95
+ failed: number;
96
+ }>;
97
+ /**
98
+ * Whether `candidate` describes a later state of the transcript than `over`.
99
+ *
100
+ * A total order over snapshots of one file, so two hook processes enqueueing
101
+ * at once reach the same verdict and the most advanced snapshot is never the
102
+ * one superseded. Bytes lead because a transcript that has grown has strictly
103
+ * more in it; mtime settles a same-length rewrite. Equal on all three means
104
+ * the same content address, which is the same job.
105
+ */
106
+ private advances;
107
+ /**
108
+ * Retires queued snapshots of the same transcript that this one has overtaken.
109
+ *
110
+ * A ready job holds a path, not bytes: whenever a worker gets to it, it
111
+ * archives whatever that path holds *then*. So two ready jobs for one
112
+ * transcript read, compress and seal the same file twice — the second run
113
+ * dedupes, but only after paying for the whole thing again. On a session that
114
+ * is appended to every few seconds that is the difference between archiving a
115
+ * 4 GB rollout once and archiving it on every write gap, which is how
116
+ * ~/.codex/sessions reached 78 GB across 13 active days.
117
+ *
118
+ * Nothing is lost by dropping the earlier job: the bytes it would have
119
+ * archived are the bytes still on disk, and the surviving job archives those.
120
+ * Only `ready` jobs are retired — a leased job may already be mid-archive, and
121
+ * a done or failed job is the record of something that actually happened.
122
+ */
123
+ private supersede;
124
+ /** Records intent to archive a transcript. It never reads transcript contents. */
125
+ enqueue(request: EnqueueRequest): Promise<{
126
+ job: ArchiveJob;
127
+ deduped: boolean;
128
+ superseded: string[];
129
+ }>;
130
+ private claimable;
131
+ /** Claims from this instance run one at a time; see `claim`. */
132
+ private claimTurn;
133
+ /**
134
+ * Hands out one job under a time-boxed lease. An expired lease is reclaimed
135
+ * automatically. `excluding` lets a drain reach past jobs it has already
136
+ * refused this pass — without it, one transcript too large for the free disk
137
+ * sat at the head of the queue and starved every smaller job behind it.
138
+ *
139
+ * Claims through one queue instance are serialised. A parallel drain runs
140
+ * several workers against the same instance, and two claims listing the
141
+ * directory at the same moment would pick the same candidate and both write
142
+ * a lease on it — the loser then failing its own job with a lease error.
143
+ * Serialising costs nothing next to the seal that follows, and cross-process
144
+ * claims keep the protection they always had: whoever renames second wins,
145
+ * and archiving is idempotent by source bytes.
146
+ */
147
+ claim(owner: string, excluding?: ReadonlySet<string>): Promise<ArchiveJob | null>;
148
+ private claimNext;
149
+ private assertLease;
150
+ complete(id: string, leaseId: string, result: {
151
+ archiveId: string;
152
+ ciphertextSha256: string;
153
+ bytes: number;
154
+ deduplicated?: boolean;
155
+ }): Promise<ArchiveJob>;
156
+ /**
157
+ * Puts a claimed job back without spending an attempt.
158
+ *
159
+ * `fail` is for a job that went wrong; this is for one that was never tried.
160
+ * The difference matters when the reason is transient and external — no room
161
+ * on the disk to seal it safely — because routing that through `fail` would
162
+ * count attempts against a job that has nothing wrong with it and eventually
163
+ * dead-letter a perfectly good transcript for being unlucky about timing.
164
+ */
165
+ /** Progress on the job being worked — visible, not merely true. */
166
+ progress(id: string, leaseId: string, bytes: number, of: number): Promise<void>;
167
+ /**
168
+ * Takes a waiting job back out of the queue. Only `ready` and `failed` jobs
169
+ * can go: a leased one is being sealed this moment and will finish or fail
170
+ * on its own, and a done one is not in the queue anymore — it is an archive,
171
+ * and removing that is a different, bigger decision made elsewhere.
172
+ */
173
+ remove(id: string): Promise<ArchiveJob>;
174
+ release(id: string, leaseId: string, reason?: {
175
+ code: string;
176
+ message: string;
177
+ }): Promise<ArchiveJob>;
178
+ /** Reschedules with exponential backoff, or dead-letters once attempts are exhausted. */
179
+ fail(id: string, leaseId: string, error: {
180
+ code: string;
181
+ message: string;
182
+ }): Promise<ArchiveJob>;
183
+ /** Explicit operator action: a dead-lettered job never revives itself. */
184
+ retry(id: string): Promise<ArchiveJob>;
185
+ }
@@ -0,0 +1,286 @@
1
+ import { link, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { join, resolve } from "node:path";
4
+ import { fail } from "./errors.js";
5
+ const JOB_VERSION = 1;
6
+ export function queueDir(dataDir) { return join(dataDir, "queue"); }
7
+ /**
8
+ * Two lifecycle events describing the same unchanged transcript produce the same
9
+ * key, so a `PostCompact` followed by a `SessionEnd` archives once.
10
+ */
11
+ export function jobId(input) {
12
+ return createHash("sha256").update(`vaultline-job:v1\n${input.agent}\n${resolve(input.sourcePath)}\n${input.bytes}\n${Math.trunc(input.modifiedAtMs)}`).digest("hex");
13
+ }
14
+ /**
15
+ * A restart-safe, file-backed work queue. Each job is one atomically written
16
+ * file named by its content-addressed id, so concurrent hook processes cannot
17
+ * create duplicates and an interrupted worker leaves recoverable state.
18
+ */
19
+ export class ArchiveQueue {
20
+ root;
21
+ now;
22
+ leaseMs;
23
+ maxAttempts;
24
+ backoffMs;
25
+ maxBackoffMs;
26
+ constructor(dataDir, options = {}) {
27
+ this.root = queueDir(dataDir);
28
+ this.now = options.now ?? Date.now;
29
+ this.leaseMs = options.leaseMs ?? 5 * 60_000;
30
+ this.maxAttempts = options.maxAttempts ?? 5;
31
+ this.backoffMs = options.backoffMs ?? 15_000;
32
+ this.maxBackoffMs = options.maxBackoffMs ?? 30 * 60_000;
33
+ }
34
+ path(id) { return join(this.root, `${id}.json`); }
35
+ stamp() { return new Date(this.now()).toISOString(); }
36
+ async writeAtomic(job) {
37
+ const target = this.path(job.id);
38
+ const temp = `${target}.${randomUUID()}.tmp`;
39
+ await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
40
+ await rename(temp, target);
41
+ }
42
+ /** Creates the file only if the id is unseen; the content is complete before it becomes visible. */
43
+ async createExclusive(job) {
44
+ const target = this.path(job.id);
45
+ const temp = `${target}.${randomUUID()}.tmp`;
46
+ await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
47
+ try {
48
+ await link(temp, target);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ if (error.code === "EEXIST")
53
+ return false;
54
+ throw error;
55
+ }
56
+ finally {
57
+ await rm(temp, { force: true });
58
+ }
59
+ }
60
+ async get(id) {
61
+ try {
62
+ return JSON.parse(await readFile(this.path(id), "utf8"));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ async require(id) {
69
+ const job = await this.get(id);
70
+ return job ?? fail("queue_job_not_found", `No queued job with id ${id}`, { jobId: id });
71
+ }
72
+ async list(filter) {
73
+ let names;
74
+ try {
75
+ names = await readdir(this.root);
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const jobs = [];
81
+ for (const name of names) {
82
+ if (!name.endsWith(".json"))
83
+ continue;
84
+ try {
85
+ jobs.push(JSON.parse(await readFile(join(this.root, name), "utf8")));
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ }
91
+ return jobs.filter((job) => !filter?.status || job.status === filter.status).sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt) || a.id.localeCompare(b.id));
92
+ }
93
+ async stats() {
94
+ const jobs = await this.list();
95
+ const count = (status) => jobs.filter((job) => job.status === status).length;
96
+ return { total: jobs.length, ready: count("ready"), leased: count("leased"), done: count("done"), failed: count("failed") };
97
+ }
98
+ /**
99
+ * Whether `candidate` describes a later state of the transcript than `over`.
100
+ *
101
+ * A total order over snapshots of one file, so two hook processes enqueueing
102
+ * at once reach the same verdict and the most advanced snapshot is never the
103
+ * one superseded. Bytes lead because a transcript that has grown has strictly
104
+ * more in it; mtime settles a same-length rewrite. Equal on all three means
105
+ * the same content address, which is the same job.
106
+ */
107
+ advances(candidate, over) {
108
+ if (candidate.sourceBytes !== over.sourceBytes)
109
+ return candidate.sourceBytes > over.sourceBytes;
110
+ const [later, earlier] = [Date.parse(candidate.sourceModifiedAt), Date.parse(over.sourceModifiedAt)];
111
+ if (later !== earlier)
112
+ return later > earlier;
113
+ return candidate.id > over.id;
114
+ }
115
+ /**
116
+ * Retires queued snapshots of the same transcript that this one has overtaken.
117
+ *
118
+ * A ready job holds a path, not bytes: whenever a worker gets to it, it
119
+ * archives whatever that path holds *then*. So two ready jobs for one
120
+ * transcript read, compress and seal the same file twice — the second run
121
+ * dedupes, but only after paying for the whole thing again. On a session that
122
+ * is appended to every few seconds that is the difference between archiving a
123
+ * 4 GB rollout once and archiving it on every write gap, which is how
124
+ * ~/.codex/sessions reached 78 GB across 13 active days.
125
+ *
126
+ * Nothing is lost by dropping the earlier job: the bytes it would have
127
+ * archived are the bytes still on disk, and the surviving job archives those.
128
+ * Only `ready` jobs are retired — a leased job may already be mid-archive, and
129
+ * a done or failed job is the record of something that actually happened.
130
+ */
131
+ async supersede(winner) {
132
+ // A dead-lettered job archives nothing, so it may not retire work either.
133
+ if (winner.status === "failed")
134
+ return [];
135
+ const retired = [];
136
+ for (const job of await this.list({ status: "ready" })) {
137
+ if (job.id === winner.id || job.agent !== winner.agent || job.sourcePath !== winner.sourcePath)
138
+ continue;
139
+ if (!this.advances(winner, job))
140
+ continue;
141
+ await this.writeAtomic({ ...job, status: "done", updatedAt: this.stamp(), lease: undefined, supersededBy: winner.id });
142
+ retired.push(job.id);
143
+ }
144
+ return retired;
145
+ }
146
+ /** Records intent to archive a transcript. It never reads transcript contents. */
147
+ async enqueue(request) {
148
+ const absolute = resolve(request.sourcePath);
149
+ let source;
150
+ try {
151
+ source = await stat(absolute);
152
+ }
153
+ catch {
154
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
155
+ }
156
+ if (!source.isFile())
157
+ fail("source_unreadable", `Transcript is not a file: ${absolute}`, { sourcePath: absolute });
158
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
159
+ const id = jobId({ agent: request.agent, sourcePath: absolute, bytes: source.size, modifiedAtMs: source.mtimeMs });
160
+ const timestamp = this.stamp();
161
+ const job = {
162
+ version: JOB_VERSION, id, status: "ready", agent: request.agent, sourcePath: absolute, event: request.event, sessionId: request.sessionId,
163
+ sourceBytes: source.size, sourceModifiedAt: new Date(source.mtimeMs).toISOString(),
164
+ enqueuedAt: timestamp, updatedAt: timestamp, attempts: 0, nextAttemptAt: timestamp
165
+ };
166
+ if (await this.createExclusive(job))
167
+ return { job, deduped: false, superseded: await this.supersede(job) };
168
+ const existing = await this.require(id);
169
+ return { job: existing, deduped: true, superseded: await this.supersede(existing) };
170
+ }
171
+ claimable(job, at) {
172
+ if (job.status === "ready")
173
+ return Date.parse(job.nextAttemptAt) <= at;
174
+ if (job.status === "leased")
175
+ return !job.lease || Date.parse(job.lease.expiresAt) <= at;
176
+ return false;
177
+ }
178
+ /** Claims from this instance run one at a time; see `claim`. */
179
+ claimTurn = Promise.resolve();
180
+ /**
181
+ * Hands out one job under a time-boxed lease. An expired lease is reclaimed
182
+ * automatically. `excluding` lets a drain reach past jobs it has already
183
+ * refused this pass — without it, one transcript too large for the free disk
184
+ * sat at the head of the queue and starved every smaller job behind it.
185
+ *
186
+ * Claims through one queue instance are serialised. A parallel drain runs
187
+ * several workers against the same instance, and two claims listing the
188
+ * directory at the same moment would pick the same candidate and both write
189
+ * a lease on it — the loser then failing its own job with a lease error.
190
+ * Serialising costs nothing next to the seal that follows, and cross-process
191
+ * claims keep the protection they always had: whoever renames second wins,
192
+ * and archiving is idempotent by source bytes.
193
+ */
194
+ async claim(owner, excluding) {
195
+ const turn = this.claimTurn.then(() => this.claimNext(owner, excluding));
196
+ this.claimTurn = turn.then(() => undefined, () => undefined);
197
+ return turn;
198
+ }
199
+ async claimNext(owner, excluding) {
200
+ const at = this.now();
201
+ const candidate = (await this.list()).find((job) => this.claimable(job, at) && !excluding?.has(job.id));
202
+ if (!candidate)
203
+ return null;
204
+ const leased = {
205
+ ...candidate, status: "leased", attempts: candidate.attempts + 1, updatedAt: new Date(at).toISOString(),
206
+ lease: { id: randomUUID(), owner, acquiredAt: new Date(at).toISOString(), expiresAt: new Date(at + this.leaseMs).toISOString() }
207
+ };
208
+ await this.writeAtomic(leased);
209
+ return leased;
210
+ }
211
+ assertLease(job, leaseId) {
212
+ if (job.status !== "leased" || !job.lease)
213
+ fail("queue_lease_invalid", `Job ${job.id} is not leased`, { jobId: job.id, status: job.status });
214
+ if (job.lease.id !== leaseId)
215
+ fail("queue_lease_invalid", `Lease does not match job ${job.id}`, { jobId: job.id });
216
+ if (Date.parse(job.lease.expiresAt) <= this.now())
217
+ fail("queue_lease_expired", `Lease for job ${job.id} expired at ${job.lease.expiresAt}`, { jobId: job.id, expiresAt: job.lease.expiresAt });
218
+ return job;
219
+ }
220
+ async complete(id, leaseId, result) {
221
+ const job = this.assertLease(await this.require(id), leaseId);
222
+ const done = { ...job, status: "done", updatedAt: this.stamp(), lease: undefined, lastError: undefined, result: { ...result, completedAt: this.stamp() } };
223
+ await this.writeAtomic(done);
224
+ return done;
225
+ }
226
+ /**
227
+ * Puts a claimed job back without spending an attempt.
228
+ *
229
+ * `fail` is for a job that went wrong; this is for one that was never tried.
230
+ * The difference matters when the reason is transient and external — no room
231
+ * on the disk to seal it safely — because routing that through `fail` would
232
+ * count attempts against a job that has nothing wrong with it and eventually
233
+ * dead-letter a perfectly good transcript for being unlucky about timing.
234
+ */
235
+ /** Progress on the job being worked — visible, not merely true. */
236
+ async progress(id, leaseId, bytes, of) {
237
+ const job = this.assertLease(await this.require(id), leaseId);
238
+ await this.writeAtomic({ ...job, progress: { bytes, of }, updatedAt: this.stamp() });
239
+ }
240
+ /**
241
+ * Takes a waiting job back out of the queue. Only `ready` and `failed` jobs
242
+ * can go: a leased one is being sealed this moment and will finish or fail
243
+ * on its own, and a done one is not in the queue anymore — it is an archive,
244
+ * and removing that is a different, bigger decision made elsewhere.
245
+ */
246
+ async remove(id) {
247
+ const job = await this.require(id);
248
+ if (job.status === "leased")
249
+ fail("invalid_argument", "This one is being sealed right now — it finishes (or fails) on its own in a moment.", { jobId: id });
250
+ if (job.status === "done")
251
+ fail("invalid_argument", "Already sealed. What exists now is an archive; delete that from the vault, not from the queue.", { jobId: id });
252
+ await rm(this.path(id), { force: true });
253
+ return { ...job, status: "failed", updatedAt: this.stamp(), lastError: { code: "removed", message: "Taken out of the queue by hand" } };
254
+ }
255
+ async release(id, leaseId, reason) {
256
+ const job = this.assertLease(await this.require(id), leaseId);
257
+ const back = {
258
+ ...job, status: "ready", updatedAt: this.stamp(), lease: undefined,
259
+ attempts: Math.max(0, job.attempts - 1),
260
+ ...(reason ? { lastError: reason } : {})
261
+ };
262
+ await this.writeAtomic(back);
263
+ return back;
264
+ }
265
+ /** Reschedules with exponential backoff, or dead-letters once attempts are exhausted. */
266
+ async fail(id, leaseId, error) {
267
+ const job = this.assertLease(await this.require(id), leaseId);
268
+ const exhausted = job.attempts >= this.maxAttempts;
269
+ const delay = Math.min(this.backoffMs * 2 ** Math.max(0, job.attempts - 1), this.maxBackoffMs);
270
+ const next = {
271
+ ...job, status: exhausted ? "failed" : "ready", updatedAt: this.stamp(), lease: undefined, lastError: error,
272
+ nextAttemptAt: exhausted ? job.nextAttemptAt : new Date(this.now() + delay).toISOString()
273
+ };
274
+ await this.writeAtomic(next);
275
+ return next;
276
+ }
277
+ /** Explicit operator action: a dead-lettered job never revives itself. */
278
+ async retry(id) {
279
+ const job = await this.require(id);
280
+ if (job.status === "done")
281
+ fail("invalid_argument", `Job ${id} already completed`, { jobId: id });
282
+ const revived = { ...job, status: "ready", attempts: 0, updatedAt: this.stamp(), nextAttemptAt: this.stamp(), lease: undefined };
283
+ await this.writeAtomic(revived);
284
+ return revived;
285
+ }
286
+ }
@@ -0,0 +1,40 @@
1
+ import { isMnemonic, PHRASE_WORDS } from "./mnemonic.js";
2
+ import type { VaultConfig } from "./types.js";
3
+ /**
4
+ * The recovery kit.
5
+ *
6
+ * Everything a person needs to get their archives back, except the one thing that
7
+ * must never be stored: the phrase itself. The sheet leaves numbered blanks for it,
8
+ * because the point of a written kit is that it lives on paper, in a drawer, far
9
+ * from the machine it unlocks.
10
+ */
11
+ /**
12
+ * The sheet someone reads during an actual disaster.
13
+ *
14
+ * `managed` says this vault pushes to Sealkeep Cloud. Without it the kit told
15
+ * a paying customer "Storage: this machine only" and left the cloud steps out
16
+ * of the restore instructions — so the one document they reach for after
17
+ * losing the laptop would tell them their archives went with it.
18
+ */
19
+ export declare function recoveryKit(config: VaultConfig, options?: {
20
+ now?: Date;
21
+ managed?: {
22
+ email: string;
23
+ } | null;
24
+ }): string;
25
+ export type VerifyResult = {
26
+ valid: boolean;
27
+ matchesVault: boolean;
28
+ problems: string[];
29
+ };
30
+ /** Checks a typed phrase for shape, checksum, and whether it opens this vault. */
31
+ export declare function verifyPhrase(config: VaultConfig, typed: string): VerifyResult;
32
+ /**
33
+ * Asks the person to type their phrase back, the way a wallet does.
34
+ *
35
+ * Input is read without echo so it does not end up in a screenshot or a scrollback
36
+ * buffer, and it is never written anywhere.
37
+ */
38
+ export declare function promptForPhrase(prompt?: string): Promise<string>;
39
+ export declare function verifyAgainstVault(dataDir: string, typed: string): Promise<VerifyResult>;
40
+ export { isMnemonic, PHRASE_WORDS };