@cello-protocol/daemon 0.0.181 → 0.0.182

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 (164) hide show
  1. package/dist/agent-id-migration.d.ts +9 -0
  2. package/dist/agent-id-migration.d.ts.map +1 -1
  3. package/dist/agent-id-migration.js +73 -1
  4. package/dist/agent-id-migration.js.map +1 -1
  5. package/dist/agent-selection.d.ts +73 -0
  6. package/dist/agent-selection.d.ts.map +1 -0
  7. package/dist/agent-selection.js +80 -0
  8. package/dist/agent-selection.js.map +1 -0
  9. package/dist/assignment-verify.d.ts +62 -0
  10. package/dist/assignment-verify.d.ts.map +1 -0
  11. package/dist/assignment-verify.js +185 -0
  12. package/dist/assignment-verify.js.map +1 -0
  13. package/dist/backup-restore.d.ts +102 -0
  14. package/dist/backup-restore.d.ts.map +1 -0
  15. package/dist/backup-restore.js +340 -0
  16. package/dist/backup-restore.js.map +1 -0
  17. package/dist/bin/cello-daemon.js +91 -49
  18. package/dist/bin/cello-daemon.js.map +1 -1
  19. package/dist/close-commitment.d.ts +67 -0
  20. package/dist/close-commitment.d.ts.map +1 -0
  21. package/dist/close-commitment.js +78 -0
  22. package/dist/close-commitment.js.map +1 -0
  23. package/dist/close-session-handler.d.ts +20 -0
  24. package/dist/close-session-handler.d.ts.map +1 -1
  25. package/dist/close-session-handler.js +351 -58
  26. package/dist/close-session-handler.js.map +1 -1
  27. package/dist/consortium-bootstrap.d.ts +2 -0
  28. package/dist/consortium-bootstrap.d.ts.map +1 -1
  29. package/dist/consortium-bootstrap.js +65 -7
  30. package/dist/consortium-bootstrap.js.map +1 -1
  31. package/dist/content-park.d.ts.map +1 -1
  32. package/dist/content-park.js +141 -10
  33. package/dist/content-park.js.map +1 -1
  34. package/dist/daemon.d.ts +13 -1
  35. package/dist/daemon.d.ts.map +1 -1
  36. package/dist/daemon.js +866 -122
  37. package/dist/daemon.js.map +1 -1
  38. package/dist/delivery-session-suspects.d.ts.map +1 -1
  39. package/dist/delivery-session-suspects.js +5 -0
  40. package/dist/delivery-session-suspects.js.map +1 -1
  41. package/dist/directory-auth-posture.d.ts +87 -0
  42. package/dist/directory-auth-posture.d.ts.map +1 -0
  43. package/dist/directory-auth-posture.js +134 -0
  44. package/dist/directory-auth-posture.js.map +1 -0
  45. package/dist/directory-bootstrap.d.ts +66 -3
  46. package/dist/directory-bootstrap.d.ts.map +1 -1
  47. package/dist/directory-bootstrap.js +133 -7
  48. package/dist/directory-bootstrap.js.map +1 -1
  49. package/dist/document-delivery-transport.d.ts +22 -2
  50. package/dist/document-delivery-transport.d.ts.map +1 -1
  51. package/dist/document-delivery-transport.js +11 -3
  52. package/dist/document-delivery-transport.js.map +1 -1
  53. package/dist/inbound-sessions.d.ts +31 -1
  54. package/dist/inbound-sessions.d.ts.map +1 -1
  55. package/dist/inbound-sessions.js +276 -16
  56. package/dist/inbound-sessions.js.map +1 -1
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/ipc-server.d.ts +5 -1
  62. package/dist/ipc-server.d.ts.map +1 -1
  63. package/dist/ipc-server.js +19 -3
  64. package/dist/ipc-server.js.map +1 -1
  65. package/dist/manifest-validity.d.ts +151 -0
  66. package/dist/manifest-validity.d.ts.map +1 -0
  67. package/dist/manifest-validity.js +268 -0
  68. package/dist/manifest-validity.js.map +1 -0
  69. package/dist/notification-handlers.d.ts.map +1 -1
  70. package/dist/notification-handlers.js +15 -0
  71. package/dist/notification-handlers.js.map +1 -1
  72. package/dist/outbound-sessions.d.ts +31 -0
  73. package/dist/outbound-sessions.d.ts.map +1 -1
  74. package/dist/outbound-sessions.js +198 -17
  75. package/dist/outbound-sessions.js.map +1 -1
  76. package/dist/park-envelope.d.ts +72 -1
  77. package/dist/park-envelope.d.ts.map +1 -1
  78. package/dist/park-envelope.js +119 -4
  79. package/dist/park-envelope.js.map +1 -1
  80. package/dist/refusal-reasons.d.ts +72 -0
  81. package/dist/refusal-reasons.d.ts.map +1 -0
  82. package/dist/refusal-reasons.js +69 -0
  83. package/dist/refusal-reasons.js.map +1 -0
  84. package/dist/registration-manager.d.ts.map +1 -1
  85. package/dist/registration-manager.js +20 -1
  86. package/dist/registration-manager.js.map +1 -1
  87. package/dist/restart-seal-resolver.d.ts.map +1 -1
  88. package/dist/restart-seal-resolver.js +6 -0
  89. package/dist/restart-seal-resolver.js.map +1 -1
  90. package/dist/retry-queue.d.ts +23 -1
  91. package/dist/retry-queue.d.ts.map +1 -1
  92. package/dist/retry-queue.js +37 -9
  93. package/dist/retry-queue.js.map +1 -1
  94. package/dist/roster-freshness.d.ts +169 -0
  95. package/dist/roster-freshness.d.ts.map +1 -0
  96. package/dist/roster-freshness.js +249 -0
  97. package/dist/roster-freshness.js.map +1 -0
  98. package/dist/seal-certificate-pull.d.ts +17 -1
  99. package/dist/seal-certificate-pull.d.ts.map +1 -1
  100. package/dist/seal-certificate-pull.js +37 -3
  101. package/dist/seal-certificate-pull.js.map +1 -1
  102. package/dist/seal-coordinator.d.ts +17 -1
  103. package/dist/seal-coordinator.d.ts.map +1 -1
  104. package/dist/seal-coordinator.js +71 -0
  105. package/dist/seal-coordinator.js.map +1 -1
  106. package/dist/seal-failure-store.d.ts +104 -0
  107. package/dist/seal-failure-store.d.ts.map +1 -0
  108. package/dist/seal-failure-store.js +126 -0
  109. package/dist/seal-failure-store.js.map +1 -0
  110. package/dist/seal-flows.d.ts.map +1 -1
  111. package/dist/seal-flows.js +35 -6
  112. package/dist/seal-flows.js.map +1 -1
  113. package/dist/seal-upgrade.d.ts +20 -5
  114. package/dist/seal-upgrade.d.ts.map +1 -1
  115. package/dist/seal-upgrade.js +28 -5
  116. package/dist/seal-upgrade.js.map +1 -1
  117. package/dist/session-assignment-parser.d.ts +41 -5
  118. package/dist/session-assignment-parser.d.ts.map +1 -1
  119. package/dist/session-assignment-parser.js +77 -14
  120. package/dist/session-assignment-parser.js.map +1 -1
  121. package/dist/session-ceremony.d.ts +29 -0
  122. package/dist/session-ceremony.d.ts.map +1 -1
  123. package/dist/session-ceremony.js +71 -0
  124. package/dist/session-ceremony.js.map +1 -1
  125. package/dist/session-connection-gater.d.ts +46 -7
  126. package/dist/session-connection-gater.d.ts.map +1 -1
  127. package/dist/session-connection-gater.js +163 -12
  128. package/dist/session-connection-gater.js.map +1 -1
  129. package/dist/session-content-handlers.d.ts.map +1 -1
  130. package/dist/session-content-handlers.js +110 -4
  131. package/dist/session-content-handlers.js.map +1 -1
  132. package/dist/session-node-manager.d.ts +406 -36
  133. package/dist/session-node-manager.d.ts.map +1 -1
  134. package/dist/session-node-manager.js +3025 -245
  135. package/dist/session-node-manager.js.map +1 -1
  136. package/dist/session-read-handlers.d.ts +27 -0
  137. package/dist/session-read-handlers.d.ts.map +1 -1
  138. package/dist/session-read-handlers.js +112 -6
  139. package/dist/session-read-handlers.js.map +1 -1
  140. package/dist/session-relay-client.d.ts +61 -2
  141. package/dist/session-relay-client.d.ts.map +1 -1
  142. package/dist/session-relay-client.js +170 -11
  143. package/dist/session-relay-client.js.map +1 -1
  144. package/dist/session-salt-agreement.d.ts +284 -0
  145. package/dist/session-salt-agreement.d.ts.map +1 -0
  146. package/dist/session-salt-agreement.js +371 -0
  147. package/dist/session-salt-agreement.js.map +1 -0
  148. package/dist/signal-submission.d.ts.map +1 -1
  149. package/dist/signal-submission.js +10 -1
  150. package/dist/signal-submission.js.map +1 -1
  151. package/dist/signaling-connect.d.ts.map +1 -1
  152. package/dist/signaling-connect.js +42 -5
  153. package/dist/signaling-connect.js.map +1 -1
  154. package/dist/types.d.ts +54 -0
  155. package/dist/types.d.ts.map +1 -1
  156. package/dist/types.js.map +1 -1
  157. package/dist/vocabulary.d.ts.map +1 -1
  158. package/dist/vocabulary.js +10 -0
  159. package/dist/vocabulary.js.map +1 -1
  160. package/dist/wire-content-hash.d.ts +56 -0
  161. package/dist/wire-content-hash.d.ts.map +1 -1
  162. package/dist/wire-content-hash.js +71 -0
  163. package/dist/wire-content-hash.js.map +1 -1
  164. package/package.json +5 -5
@@ -0,0 +1,102 @@
1
+ /**
2
+ * DOD-M15-BACKUP-1 — export an agent's identity, and put it back on another machine.
3
+ *
4
+ * ─── What a backup has to contain, and why the obvious answer is wrong ─────────────────────────
5
+ *
6
+ * The DoD line says *"Backup = exporting the SQLCipher database for transport."* Exporting the
7
+ * database alone produces a file nobody can ever open.
8
+ *
9
+ * The database is encrypted and its key is a separate 32-byte file at `<db>.key` — `sqlcipher-db.ts`
10
+ * calls it "the ONE plaintext key file on disk". A fresh daemon on a new machine mints its own key,
11
+ * which cannot open a database encrypted under a different one. An operator restoring a
12
+ * database-only archive would be told it worked, and find out on the day they needed it that the
13
+ * contents are unreadable forever.
14
+ *
15
+ * So the archive carries BOTH, and the round-trip test restores into a directory holding a
16
+ * different key precisely to prove it.
17
+ *
18
+ * ─── The consequence of that, which the tool must say out loud ─────────────────────────────────
19
+ *
20
+ * A file containing that key IS the agent. Whoever holds it can sign as them, read every
21
+ * transcript, and use their identity. Nothing about the word "backup" suggests a file this
22
+ * dangerous, so the response says it at the moment one is written. `DOD-M15-CLAIM-SCREEN-1`'s rule
23
+ * applies to affordances as much as claims: silence is not an option.
24
+ *
25
+ * ─── Why `VACUUM INTO` rather than copying the file ────────────────────────────────────────────
26
+ *
27
+ * The daemon holds the database open with a write lock. Copying the file byte-for-byte while it is
28
+ * open can capture a torn page or miss a WAL frame, producing an archive that restores into a
29
+ * corrupt database — the worst outcome available here, because it looks like a successful backup.
30
+ * `VACUUM INTO` asks SQLite for a consistent snapshot, and on SQLCipher the target inherits the
31
+ * source's cipher settings, so the snapshot is encrypted under the same key.
32
+ *
33
+ * ─── Restore is validated in full BEFORE anything is touched ───────────────────────────────────
34
+ *
35
+ * Restore OVERWRITES. A truncated or wrong file that is accepted destroys the working agent the
36
+ * operator still had — the backup tool becomes the thing that loses the identity. So the container
37
+ * is parsed, its checksum verified and its payloads decompressed entirely in memory first; only
38
+ * then does anything reach the disk.
39
+ *
40
+ * ─── V1 scope, per the DoD ─────────────────────────────────────────────────────────────────────
41
+ *
42
+ * Export plus overwrite-restore. MERGE — restoring onto a device that has its own live state — is
43
+ * explicitly deferred, and this module does not pretend otherwise: restore replaces, and says so.
44
+ */
45
+ import type { Logger } from "./types.js";
46
+ export type BackupResult = {
47
+ ok: true;
48
+ path: string;
49
+ bytes: number;
50
+ guidance: string;
51
+ } | {
52
+ ok: false;
53
+ reason: string;
54
+ guidance: string;
55
+ };
56
+ export type RestoreResult = {
57
+ ok: true;
58
+ dbPath: string;
59
+ guidance: string;
60
+ } | {
61
+ ok: false;
62
+ reason: string;
63
+ guidance: string;
64
+ };
65
+ /**
66
+ * Take a consistent snapshot of the live database and write a portable archive.
67
+ *
68
+ * `dbPath` is the live database. It may be open in this process; `VACUUM INTO` is safe against that
69
+ * and is why the snapshot goes through SQLite rather than a file copy.
70
+ */
71
+ export declare function createBackup(opts: {
72
+ dbPath: string;
73
+ outPath: string;
74
+ logger: Logger;
75
+ keyPath?: string;
76
+ /** A backup is not a scratch file: replacing one is deliberate, never incidental. */
77
+ overwrite?: boolean;
78
+ }): Promise<BackupResult>;
79
+ /** Read an archive's metadata without restoring it. */
80
+ export declare function inspectBackup(archivePath: string): Promise<{
81
+ ok: true;
82
+ createdAt: number;
83
+ hasKey: boolean;
84
+ dbBytes: number;
85
+ } | {
86
+ ok: false;
87
+ reason: string;
88
+ }>;
89
+ /**
90
+ * Replace this machine's database and key with the archive's.
91
+ *
92
+ * OVERWRITE, not merge — merge is explicitly deferred, and pretending otherwise would silently pick
93
+ * a winner between two divergent histories. The caller is responsible for the daemon not holding
94
+ * the database open; the handler enforces that.
95
+ */
96
+ export declare function restoreBackup(opts: {
97
+ archivePath: string;
98
+ dbPath: string;
99
+ logger: Logger;
100
+ keyPath?: string;
101
+ }): Promise<RestoreResult>;
102
+ //# sourceMappingURL=backup-restore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-restore.d.ts","sourceRoot":"","sources":["../src/backup-restore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAQH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAoCzC,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC9C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAYpD;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,GAAG,OAAO,CAAC,YAAY,CAAC,CA0FxB;AAED,uDAAuD;AACvD,wBAAsB,aAAa,CACjC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAS5G;AA0DD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,aAAa,CAAC,CA6FzB"}
@@ -0,0 +1,340 @@
1
+ /**
2
+ * DOD-M15-BACKUP-1 — export an agent's identity, and put it back on another machine.
3
+ *
4
+ * ─── What a backup has to contain, and why the obvious answer is wrong ─────────────────────────
5
+ *
6
+ * The DoD line says *"Backup = exporting the SQLCipher database for transport."* Exporting the
7
+ * database alone produces a file nobody can ever open.
8
+ *
9
+ * The database is encrypted and its key is a separate 32-byte file at `<db>.key` — `sqlcipher-db.ts`
10
+ * calls it "the ONE plaintext key file on disk". A fresh daemon on a new machine mints its own key,
11
+ * which cannot open a database encrypted under a different one. An operator restoring a
12
+ * database-only archive would be told it worked, and find out on the day they needed it that the
13
+ * contents are unreadable forever.
14
+ *
15
+ * So the archive carries BOTH, and the round-trip test restores into a directory holding a
16
+ * different key precisely to prove it.
17
+ *
18
+ * ─── The consequence of that, which the tool must say out loud ─────────────────────────────────
19
+ *
20
+ * A file containing that key IS the agent. Whoever holds it can sign as them, read every
21
+ * transcript, and use their identity. Nothing about the word "backup" suggests a file this
22
+ * dangerous, so the response says it at the moment one is written. `DOD-M15-CLAIM-SCREEN-1`'s rule
23
+ * applies to affordances as much as claims: silence is not an option.
24
+ *
25
+ * ─── Why `VACUUM INTO` rather than copying the file ────────────────────────────────────────────
26
+ *
27
+ * The daemon holds the database open with a write lock. Copying the file byte-for-byte while it is
28
+ * open can capture a torn page or miss a WAL frame, producing an archive that restores into a
29
+ * corrupt database — the worst outcome available here, because it looks like a successful backup.
30
+ * `VACUUM INTO` asks SQLite for a consistent snapshot, and on SQLCipher the target inherits the
31
+ * source's cipher settings, so the snapshot is encrypted under the same key.
32
+ *
33
+ * ─── Restore is validated in full BEFORE anything is touched ───────────────────────────────────
34
+ *
35
+ * Restore OVERWRITES. A truncated or wrong file that is accepted destroys the working agent the
36
+ * operator still had — the backup tool becomes the thing that loses the identity. So the container
37
+ * is parsed, its checksum verified and its payloads decompressed entirely in memory first; only
38
+ * then does anything reach the disk.
39
+ *
40
+ * ─── V1 scope, per the DoD ─────────────────────────────────────────────────────────────────────
41
+ *
42
+ * Export plus overwrite-restore. MERGE — restoring onto a device that has its own live state — is
43
+ * explicitly deferred, and this module does not pretend otherwise: restore replaces, and says so.
44
+ */
45
+ import { createHash, randomBytes } from "node:crypto";
46
+ import { gzipSync, gunzipSync } from "node:zlib";
47
+ import { readFile, writeFile, rm, mkdir, chmod, open as fsOpen, rename } from "node:fs/promises";
48
+ import { existsSync } from "node:fs";
49
+ import { dirname, join } from "node:path";
50
+ import { openEncryptedDatabase, dbKeyPathFor } from "./sqlcipher-db.js";
51
+ /**
52
+ * Write 0600, fsync, rename — and fsync the directory so the rename itself survives a power loss.
53
+ *
54
+ * Restore OVERWRITES an identity. A partially written database is the one failure that opens and is
55
+ * WRONG rather than failing loudly, because page 1 is enough for `sqlite_master` to read. Rename is
56
+ * atomic: the path is either the old file or the whole new one.
57
+ */
58
+ async function writeFileDurably(path, bytes) {
59
+ const tmp = `${path}.restore-${randomBytes(6).toString("hex")}`;
60
+ const fh = await fsOpen(tmp, "wx", 0o600);
61
+ try {
62
+ await fh.write(bytes);
63
+ await fh.sync();
64
+ }
65
+ finally {
66
+ await fh.close();
67
+ }
68
+ await rename(tmp, path);
69
+ const dir = await fsOpen(dirname(path), "r");
70
+ try {
71
+ await dir.sync();
72
+ }
73
+ finally {
74
+ await dir.close();
75
+ }
76
+ }
77
+ /** Magic + version, so a wrong file is refused by shape before anything else is attempted. */
78
+ const MAGIC = "CELLO-BACKUP-v1";
79
+ /**
80
+ * THE SENSITIVITY NOTICE. One string, used by every path that writes an archive, so a second call
81
+ * site cannot ship a quieter version of it.
82
+ */
83
+ const SENSITIVITY = "TREAT THIS FILE LIKE A PRIVATE KEY. It contains your agent's encrypted database AND the key that " +
84
+ "opens it, because a backup without the key restores to something nobody can read. Anyone who has " +
85
+ "this file can sign as your agent and read every transcript in it. Store it where you would store " +
86
+ "a private key — not a shared drive, not a chat message.";
87
+ /**
88
+ * Take a consistent snapshot of the live database and write a portable archive.
89
+ *
90
+ * `dbPath` is the live database. It may be open in this process; `VACUUM INTO` is safe against that
91
+ * and is why the snapshot goes through SQLite rather than a file copy.
92
+ */
93
+ export async function createBackup(opts) {
94
+ const keyPath = opts.keyPath ?? dbKeyPathFor(opts.dbPath);
95
+ if (!existsSync(opts.dbPath)) {
96
+ return {
97
+ ok: false,
98
+ reason: "database_not_found",
99
+ guidance: `No database at ${opts.dbPath}. Start the daemon at least once before taking a backup — there is no identity to export yet.`,
100
+ };
101
+ }
102
+ if (!existsSync(keyPath)) {
103
+ // Refused rather than exported-without-key: an archive that cannot be opened is worse than no
104
+ // archive, because the operator stops worrying about it.
105
+ return {
106
+ ok: false,
107
+ reason: "key_file_not_found",
108
+ guidance: `The database at ${opts.dbPath} has no key file at ${keyPath}. Exporting the database without it would produce a backup nobody can ever open, so nothing was written.`,
109
+ };
110
+ }
111
+ if (existsSync(opts.outPath) && opts.overwrite !== true) {
112
+ return {
113
+ ok: false,
114
+ reason: "archive_exists",
115
+ guidance: `${opts.outPath} already exists. Replacing a backup silently is a way to lose an identity while believing you hold two copies of it — pass overwrite to replace it deliberately, or choose another path.`,
116
+ };
117
+ }
118
+ // A snapshot beside the real database, then read and discard it. `VACUUM INTO` refuses to write
119
+ // to an existing path, so the name is fresh per call.
120
+ // BESIDE THE ARCHIVE, not in the shared temp dir (review F11). On macOS `$TMPDIR` is per-user
121
+ // 0700, but on Linux it is `/tmp` and SQLite creates the snapshot at `0644 & ~umask`. The contents
122
+ // are encrypted — verified empirically, the header is ciphertext and it is unopenable without the
123
+ // key — so that is ciphertext exposure rather than key exposure. It is still a full copy of the
124
+ // database, and the `finally` below only covers throws: a SIGKILL mid-backup would leave it there.
125
+ const snapshot = join(dirname(opts.outPath), `.cello-backup-snapshot-${randomBytes(8).toString("hex")}.db`);
126
+ try {
127
+ const key = await readFile(keyPath);
128
+ const db = openEncryptedDatabase(opts.dbPath, new Uint8Array(key));
129
+ try {
130
+ db.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
131
+ }
132
+ finally {
133
+ db.close();
134
+ }
135
+ const dbBytes = await readFile(snapshot);
136
+ const envelope = {
137
+ magic: MAGIC,
138
+ version: 1,
139
+ created_at: Date.now(),
140
+ db_sha256: createHash("sha256").update(dbBytes).digest("hex"),
141
+ db_b64: dbBytes.toString("base64"),
142
+ key_b64: key.toString("base64"),
143
+ };
144
+ const packed = gzipSync(Buffer.from(JSON.stringify(envelope), "utf8"));
145
+ await writeFile(opts.outPath, packed, { mode: 0o600 });
146
+ /**
147
+ * CHMOD AFTER THE WRITE, because `mode` is honoured only at `O_CREAT` — review F5, measured on
148
+ * this machine:
149
+ *
150
+ * existing 0644 file + writeFile(mode 0600) → still 0644
151
+ * existing 0666 file + writeFile(mode 0600) → still 0666
152
+ * fresh file → 0600
153
+ *
154
+ * So the `--force` path — overwriting yesterday's archive — kept whatever mode was already
155
+ * there. A file that arrived at 0644 (scp, a shell redirect under umask 022, an iCloud
156
+ * round-trip) would hold a WORLD-READABLE plaintext signing key. The comment claimed the
157
+ * property; only the create path enforced it.
158
+ */
159
+ await chmod(opts.outPath, 0o600);
160
+ opts.logger.info("agent.backup.written", {
161
+ path: opts.outPath,
162
+ bytes: packed.length,
163
+ impact: "the agent's identity, key shares, contacts, transcripts and seals are now exportable to another machine",
164
+ guidance: SENSITIVITY,
165
+ });
166
+ return { ok: true, path: opts.outPath, bytes: packed.length, guidance: SENSITIVITY };
167
+ }
168
+ catch (err) {
169
+ const reason = err instanceof Error ? err.message : String(err);
170
+ opts.logger.error("agent.backup.failed", { path: opts.outPath, reason });
171
+ return {
172
+ ok: false,
173
+ reason: "backup_failed",
174
+ guidance: `The backup could not be written: ${reason}. Nothing was changed on this machine.`,
175
+ };
176
+ }
177
+ finally {
178
+ await rm(snapshot, { force: true }).catch(() => { });
179
+ }
180
+ }
181
+ /** Read an archive's metadata without restoring it. */
182
+ export async function inspectBackup(archivePath) {
183
+ const parsed = await parseArchive(archivePath);
184
+ if (!parsed.ok)
185
+ return { ok: false, reason: parsed.reason };
186
+ return {
187
+ ok: true,
188
+ createdAt: parsed.envelope.created_at,
189
+ hasKey: parsed.envelope.key_b64.length > 0,
190
+ dbBytes: parsed.dbBytes.length,
191
+ };
192
+ }
193
+ /**
194
+ * Parse and FULLY VALIDATE an archive in memory.
195
+ *
196
+ * Everything that can fail happens here, before `restoreBackup` touches the disk. That ordering is
197
+ * the whole safety property: a restore that fails must leave the machine exactly as it was.
198
+ */
199
+ async function parseArchive(archivePath) {
200
+ if (!existsSync(archivePath))
201
+ return { ok: false, reason: "archive_not_found" };
202
+ let raw;
203
+ try {
204
+ raw = await readFile(archivePath);
205
+ }
206
+ catch (err) {
207
+ return { ok: false, reason: `archive_unreadable: ${err instanceof Error ? err.message : String(err)}` };
208
+ }
209
+ let json;
210
+ try {
211
+ json = gunzipSync(raw).toString("utf8");
212
+ }
213
+ catch {
214
+ // A truncated file usually dies here — gzip carries its own length and CRC, which is why the
215
+ // container is compressed rather than raw JSON.
216
+ return { ok: false, reason: "archive_corrupt_or_not_a_cello_backup" };
217
+ }
218
+ let envelope;
219
+ try {
220
+ envelope = JSON.parse(json);
221
+ }
222
+ catch {
223
+ return { ok: false, reason: "archive_corrupt_or_not_a_cello_backup" };
224
+ }
225
+ if (envelope.magic !== MAGIC || envelope.version !== 1) {
226
+ return { ok: false, reason: "archive_wrong_format_or_version" };
227
+ }
228
+ if (typeof envelope.db_b64 !== "string" || typeof envelope.key_b64 !== "string") {
229
+ return { ok: false, reason: "archive_missing_payload" };
230
+ }
231
+ const dbBytes = Buffer.from(envelope.db_b64, "base64");
232
+ const keyBytes = Buffer.from(envelope.key_b64, "base64");
233
+ if (dbBytes.length === 0)
234
+ return { ok: false, reason: "archive_missing_database" };
235
+ if (keyBytes.length !== 32) {
236
+ // Without a well-formed key the restored database is unopenable — the exact failure this unit
237
+ // exists to prevent, so it is refused rather than restored hopefully.
238
+ return { ok: false, reason: "archive_missing_or_malformed_key" };
239
+ }
240
+ const actual = createHash("sha256").update(dbBytes).digest("hex");
241
+ if (actual !== envelope.db_sha256)
242
+ return { ok: false, reason: "archive_checksum_mismatch" };
243
+ return { ok: true, envelope, dbBytes, keyBytes };
244
+ }
245
+ /**
246
+ * Replace this machine's database and key with the archive's.
247
+ *
248
+ * OVERWRITE, not merge — merge is explicitly deferred, and pretending otherwise would silently pick
249
+ * a winner between two divergent histories. The caller is responsible for the daemon not holding
250
+ * the database open; the handler enforces that.
251
+ */
252
+ export async function restoreBackup(opts) {
253
+ const keyPath = opts.keyPath ?? dbKeyPathFor(opts.dbPath);
254
+ const parsed = await parseArchive(opts.archivePath);
255
+ if (!parsed.ok) {
256
+ opts.logger.error("agent.restore.refused", {
257
+ archivePath: opts.archivePath,
258
+ reason: parsed.reason,
259
+ impact: "nothing was written — the agent currently on this machine is untouched",
260
+ });
261
+ return {
262
+ ok: false,
263
+ reason: parsed.reason,
264
+ guidance: `That file was not restored: ${parsed.reason}. NOTHING on this machine was changed — the ` +
265
+ `archive is validated completely before anything is written, because a restore overwrites ` +
266
+ `and a bad archive must never be able to destroy the agent you still have.`,
267
+ };
268
+ }
269
+ try {
270
+ // 0700, matching `resolveDbKey`'s own mkdir. On a fresh machine THIS is what creates the CELLO
271
+ // directory, and it holds a signing key (review F12).
272
+ await mkdir(dirname(opts.dbPath), { recursive: true, mode: 0o700 });
273
+ /**
274
+ * ORDER AND ATOMICITY — review F6/F7, and the previous comment here had it backwards.
275
+ *
276
+ * It said "key first, because a database without its key is the unopenable state". On a machine
277
+ * that ALREADY has an agent that is true but harmless — a key/db mismatch throws
278
+ * `db_encryption_key_mismatch`, loudly, and re-running the restore fixes it.
279
+ *
280
+ * On a FRESH machine — the actual restore use case — key-first is the dangerous order. Crash
281
+ * between the two writes and the key is present with NO database, so `resolveDbKey` loads the
282
+ * key and SQLCipher CREATES A FRESH EMPTY ONE. The daemon boots clean with zero agents and says
283
+ * nothing. That is the only crash state that opens, is wrong, and is silent.
284
+ *
285
+ * Worse, a crash MID-write left a truncated database whose page 1 was present, so
286
+ * `sqlite_master` read and the open SUCCEEDED against a partial file.
287
+ *
288
+ * Both are closed by writing to a temp sibling, fsync'ing, and renaming — rename is atomic, so
289
+ * the file either is the old one or is the whole new one, never half. The sidecars go first:
290
+ * stale WAL frames encrypted under the OLD key must not be present when the new database
291
+ * appears. Then the database, then the key — so the surviving crash state is db-present /
292
+ * key-absent, which `resolveDbKey` refuses loudly by design.
293
+ *
294
+ * The key write is fsync'd for the reason `sqlcipher-db.ts` gives where it writes the same file:
295
+ * "so a crash right after a fresh install can't lose the key while the encrypted DB exists".
296
+ * Returning ok:true with the key only in page cache re-opens that hole.
297
+ */
298
+ await rm(`${opts.dbPath}-wal`, { force: true }).catch(() => { });
299
+ await rm(`${opts.dbPath}-shm`, { force: true }).catch(() => { });
300
+ await writeFileDurably(opts.dbPath, parsed.dbBytes);
301
+ await writeFileDurably(keyPath, parsed.keyBytes);
302
+ }
303
+ catch (err) {
304
+ const reason = err instanceof Error ? err.message : String(err);
305
+ opts.logger.error("agent.restore.failed", { archivePath: opts.archivePath, reason });
306
+ return {
307
+ ok: false,
308
+ reason: "restore_write_failed",
309
+ guidance: `The restore could not complete: ${reason}. The database may be in a partial state — restore again from the same archive before starting the daemon.`,
310
+ };
311
+ }
312
+ // Prove it opens, here, rather than letting the daemon discover it at next boot.
313
+ try {
314
+ const db = openEncryptedDatabase(opts.dbPath, new Uint8Array(parsed.keyBytes));
315
+ db.close();
316
+ }
317
+ catch (err) {
318
+ const reason = err instanceof Error ? err.message : String(err);
319
+ opts.logger.error("agent.restore.unopenable", { dbPath: opts.dbPath, reason });
320
+ return {
321
+ ok: false,
322
+ reason: "restored_database_unopenable",
323
+ guidance: `The archive was written but the restored database could not be opened: ${reason}. This should not happen — the archive carries its own key. Keep the archive and report it.`,
324
+ };
325
+ }
326
+ const createdAt = new Date(parsed.envelope.created_at).toISOString();
327
+ opts.logger.info("agent.restore.completed", {
328
+ dbPath: opts.dbPath,
329
+ backupCreatedAt: createdAt,
330
+ impact: "this machine's agent database and key were REPLACED by the archive's",
331
+ });
332
+ return {
333
+ ok: true,
334
+ dbPath: opts.dbPath,
335
+ guidance: `Restored from a backup taken ${createdAt}. This machine's previous agent database was ` +
336
+ `REPLACED, not merged — anything that happened here since that backup is gone. Restart the ` +
337
+ `daemon (cello logout && cello login) so it opens the restored database.`,
338
+ };
339
+ }
340
+ //# sourceMappingURL=backup-restore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-restore.js","sourceRoot":"","sources":["../src/backup-restore.ts"],"names":[],"mappings":"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"}
@@ -92,6 +92,16 @@ async function startSecurityLayer(correlationId) {
92
92
  },
93
93
  });
94
94
  logger.info("security.gateway.spawned", { pid: sidecar.pid ?? -1, socketPath, ...(correlationId !== undefined ? { correlationId } : {}) });
95
+ // WHETHER THE SEMANTIC LAYER IS RUNNING, in the daemon log, at every boot.
96
+ //
97
+ // `mode: "enforcing"` on the next line says the gateway is REACHABLE, not that it judges
98
+ // meaning — and for every shipped build before daemon 0.0.181 it judged none, while reporting
99
+ // exactly that. This is the line that makes the difference legible, and `off:` carries the
100
+ // reason so the answer to "why" is in the same place as the answer to "whether".
101
+ logger.info("security.gateway.layer2", {
102
+ state: sidecar.layer2,
103
+ ...(correlationId !== undefined ? { correlationId } : {}),
104
+ });
95
105
  sidecar.process.once("exit", (code, signal) => {
96
106
  // No auto-restart (M9B-D14). Every subsequent screen fails closed with a real cause; this
97
107
  // line is how the operator learns the screening process died rather than inferring it from
@@ -173,7 +183,87 @@ async function main() {
173
183
  if (!restarted.sidecar)
174
184
  throw new Error("the screening process did not come back up");
175
185
  };
176
- const handle = await startDaemon({
186
+ /**
187
+ * ⚠️ THE SIGNAL HANDLERS ARE REGISTERED BEFORE `startDaemon` IS AWAITED, AND THE ORDER IS THE BUG.
188
+ *
189
+ * `daemon.started` is logged INSIDE `startDaemon` (`daemon.ts`), and the handlers used to be
190
+ * registered ~50 lines BELOW the `await` that resolves it. So between the daemon announcing it had
191
+ * started and it being able to handle a shutdown, there was a window in which **SIGTERM's default
192
+ * action kills the process** — no handler, no `handle.stop()`, no `UPDATE sessions SET status =
193
+ * 'interrupted'`. The daemon dies claiming nothing and every active session stays `active`.
194
+ *
195
+ * ⚠️ THIS IS A PRODUCTION DEFECT, NOT A TEST ARTIFACT, and it is worth being explicit because it
196
+ * was found through a test. Anything that starts the daemon and stops it promptly — `systemctl
197
+ * stop` on a slow boot, a supervisor restarting a flapping unit, an operator hitting Ctrl-C
198
+ * because startup looked stuck — lands in that window. The sessions are then indistinguishable
199
+ * from ones abandoned mid-conversation, which is the state `interrupted` exists to prevent.
200
+ *
201
+ * HOW IT WAS FOUND, because the route matters: `AC-009` failed twice in CI, passed locally twice,
202
+ * and its message could not say why. Instrumenting both sides — `rowsMarkedInterrupted` on the
203
+ * daemon, and refusing to treat `code === null` as a clean exit in the test — produced, on the
204
+ * very next run: *"Daemon was KILLED by SIGTERM rather than exiting — the SIGTERM handler never
205
+ * ran."* Not a WAL snapshot race, which is what it had been attributed to and "fixed" as once.
206
+ *
207
+ * A signal arriving during startup now WAITS for startup to finish and then shuts down properly,
208
+ * rather than being dropped. The alternative — exiting immediately — would be the same data loss
209
+ * with a tidier exit code.
210
+ */
211
+ let handle = null;
212
+ let startupFailed = false;
213
+ const shutdown = async (signal) => {
214
+ if (!handle) {
215
+ // The signal beat startup. Wait for it rather than dying with sessions still marked active —
216
+ // this is the window that produced the defect above.
217
+ logger.info("daemon.shutdown.awaiting_startup", {
218
+ signal,
219
+ impact: "the signal arrived before startup finished. Waiting for it so sessions are marked interrupted rather than left active by a daemon that died before it could handle a shutdown.",
220
+ });
221
+ const deadline = Date.now() + 30_000;
222
+ while (!handle && !startupFailed && Date.now() < deadline) {
223
+ await new Promise((r) => setTimeout(r, 25));
224
+ }
225
+ if (!handle) {
226
+ logger.error("daemon.shutdown.startup_never_completed", {
227
+ signal,
228
+ startupFailed,
229
+ impact: "the daemon is exiting without marking any session interrupted, because startup never produced a handle to stop. Sessions left 'active' in the database were not touched by this process.",
230
+ guidance: "Check the lines above for why startup did not complete — a singleton-lock loss, a security-gateway failure, or a manifest refusal all land here.",
231
+ });
232
+ process.exit(1);
233
+ }
234
+ }
235
+ // handle.stop() runs onShutdown (stopSecurityLayer) for us — the teardown lives there so that
236
+ // `cello logout`, which stops the daemon over IPC and never reaches this handler, tears the
237
+ // sidecar down too.
238
+ //
239
+ // DOD-LOGOUT-EXIT-1: stop()'s onStopped hook exits, so this await does not return while the
240
+ // hook is wired below — the line after is unreachable in the shipped binary. It is kept so that
241
+ // removing the hook cannot silently produce a signal path that never exits, which is the state
242
+ // the IPC path was in before this unit.
243
+ await handle.stop(signal);
244
+ process.exit(0);
245
+ };
246
+ const onSignal = (signal) => () => {
247
+ try {
248
+ shutdown(signal).catch((err) => {
249
+ logger.error("daemon.shutdown.failed", {
250
+ signal,
251
+ error: err instanceof Error ? err.message : String(err),
252
+ });
253
+ process.exit(1);
254
+ });
255
+ }
256
+ catch (err) {
257
+ logger.error("daemon.shutdown.failed", {
258
+ signal,
259
+ error: err instanceof Error ? err.message : String(err),
260
+ });
261
+ process.exit(1);
262
+ }
263
+ };
264
+ process.on("SIGTERM", onSignal("SIGTERM"));
265
+ process.on("SIGINT", onSignal("SIGINT"));
266
+ handle = await startDaemon({
177
267
  celloDir,
178
268
  socketPath,
179
269
  lockFilePath,
@@ -208,54 +298,6 @@ async function main() {
208
298
  registryPubkey: REGISTRY_SIGNER_PUBKEY,
209
299
  registryPollScheduler: new RandomizedPollScheduler({ minMs: REGISTRY_POLL_MIN_MS, maxMs: REGISTRY_POLL_MAX_MS }),
210
300
  });
211
- const shutdown = async (signal) => {
212
- // handle.stop() runs onShutdown (stopSecurityLayer) for us — the teardown lives there so that
213
- // `cello logout`, which stops the daemon over IPC and never reaches this handler, tears the
214
- // sidecar down too.
215
- //
216
- // DOD-LOGOUT-EXIT-1: stop()'s onStopped hook exits, so this await does not return while the
217
- // hook is wired above — the line below is unreachable in the shipped binary. It is kept so that
218
- // removing the hook cannot silently produce a signal path that never exits, which is the state
219
- // the IPC path was in before this unit.
220
- await handle.stop(signal);
221
- process.exit(0);
222
- };
223
- process.on("SIGTERM", () => {
224
- try {
225
- shutdown("SIGTERM").catch((err) => {
226
- logger.error("daemon.shutdown.failed", {
227
- signal: "SIGTERM",
228
- error: err instanceof Error ? err.message : String(err),
229
- });
230
- process.exit(1);
231
- });
232
- }
233
- catch (err) {
234
- logger.error("daemon.shutdown.failed", {
235
- signal: "SIGTERM",
236
- error: err instanceof Error ? err.message : String(err),
237
- });
238
- process.exit(1);
239
- }
240
- });
241
- process.on("SIGINT", () => {
242
- try {
243
- shutdown("SIGINT").catch((err) => {
244
- logger.error("daemon.shutdown.failed", {
245
- signal: "SIGINT",
246
- error: err instanceof Error ? err.message : String(err),
247
- });
248
- process.exit(1);
249
- });
250
- }
251
- catch (err) {
252
- logger.error("daemon.shutdown.failed", {
253
- signal: "SIGINT",
254
- error: err instanceof Error ? err.message : String(err),
255
- });
256
- process.exit(1);
257
- }
258
- });
259
301
  }
260
302
  main().catch((err) => {
261
303
  // DOD-SINGLE-DAEMON-1 (AC2): losing the singleton race is not a crash — it is the system working.