@cello-protocol/daemon 0.0.181 → 0.0.183

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 (197) 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/agent-settings-keys.d.ts.map +1 -1
  10. package/dist/agent-settings-keys.js +18 -0
  11. package/dist/agent-settings-keys.js.map +1 -1
  12. package/dist/assignment-verify.d.ts +62 -0
  13. package/dist/assignment-verify.d.ts.map +1 -0
  14. package/dist/assignment-verify.js +185 -0
  15. package/dist/assignment-verify.js.map +1 -0
  16. package/dist/backup-restore.d.ts +102 -0
  17. package/dist/backup-restore.d.ts.map +1 -0
  18. package/dist/backup-restore.js +340 -0
  19. package/dist/backup-restore.js.map +1 -0
  20. package/dist/bin/cello-daemon.js +91 -49
  21. package/dist/bin/cello-daemon.js.map +1 -1
  22. package/dist/close-commitment.d.ts +67 -0
  23. package/dist/close-commitment.d.ts.map +1 -0
  24. package/dist/close-commitment.js +78 -0
  25. package/dist/close-commitment.js.map +1 -0
  26. package/dist/close-session-handler.d.ts +20 -0
  27. package/dist/close-session-handler.d.ts.map +1 -1
  28. package/dist/close-session-handler.js +351 -58
  29. package/dist/close-session-handler.js.map +1 -1
  30. package/dist/consortium-bootstrap.d.ts +2 -0
  31. package/dist/consortium-bootstrap.d.ts.map +1 -1
  32. package/dist/consortium-bootstrap.js +182 -9
  33. package/dist/consortium-bootstrap.js.map +1 -1
  34. package/dist/content-encryption-status.d.ts +97 -0
  35. package/dist/content-encryption-status.d.ts.map +1 -0
  36. package/dist/content-encryption-status.js +122 -0
  37. package/dist/content-encryption-status.js.map +1 -0
  38. package/dist/content-park-client.d.ts +10 -0
  39. package/dist/content-park-client.d.ts.map +1 -1
  40. package/dist/content-park-client.js +84 -3
  41. package/dist/content-park-client.js.map +1 -1
  42. package/dist/content-park.d.ts.map +1 -1
  43. package/dist/content-park.js +323 -21
  44. package/dist/content-park.js.map +1 -1
  45. package/dist/daemon.d.ts +13 -1
  46. package/dist/daemon.d.ts.map +1 -1
  47. package/dist/daemon.js +1168 -141
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/delivery-session-suspects.d.ts.map +1 -1
  50. package/dist/delivery-session-suspects.js +5 -0
  51. package/dist/delivery-session-suspects.js.map +1 -1
  52. package/dist/directory-auth-posture.d.ts +87 -0
  53. package/dist/directory-auth-posture.d.ts.map +1 -0
  54. package/dist/directory-auth-posture.js +134 -0
  55. package/dist/directory-auth-posture.js.map +1 -0
  56. package/dist/directory-bootstrap.d.ts +66 -3
  57. package/dist/directory-bootstrap.d.ts.map +1 -1
  58. package/dist/directory-bootstrap.js +133 -7
  59. package/dist/directory-bootstrap.js.map +1 -1
  60. package/dist/document-delivery-transport.d.ts +22 -2
  61. package/dist/document-delivery-transport.d.ts.map +1 -1
  62. package/dist/document-delivery-transport.js +11 -3
  63. package/dist/document-delivery-transport.js.map +1 -1
  64. package/dist/document-handlers.d.ts.map +1 -1
  65. package/dist/document-handlers.js +119 -0
  66. package/dist/document-handlers.js.map +1 -1
  67. package/dist/document-layer.d.ts +0 -2
  68. package/dist/document-layer.d.ts.map +1 -1
  69. package/dist/document-layer.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +31 -1
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +276 -16
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +3 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/initiate-session-handler.d.ts.map +1 -1
  79. package/dist/initiate-session-handler.js +34 -3
  80. package/dist/initiate-session-handler.js.map +1 -1
  81. package/dist/ipc-server.d.ts +5 -1
  82. package/dist/ipc-server.d.ts.map +1 -1
  83. package/dist/ipc-server.js +19 -3
  84. package/dist/ipc-server.js.map +1 -1
  85. package/dist/manifest-deps.d.ts +0 -11
  86. package/dist/manifest-deps.d.ts.map +1 -1
  87. package/dist/manifest-deps.js +28 -2
  88. package/dist/manifest-deps.js.map +1 -1
  89. package/dist/manifest-validity.d.ts +153 -0
  90. package/dist/manifest-validity.d.ts.map +1 -0
  91. package/dist/manifest-validity.js +268 -0
  92. package/dist/manifest-validity.js.map +1 -0
  93. package/dist/notification-handlers.d.ts.map +1 -1
  94. package/dist/notification-handlers.js +15 -0
  95. package/dist/notification-handlers.js.map +1 -1
  96. package/dist/outbound-sessions.d.ts +31 -0
  97. package/dist/outbound-sessions.d.ts.map +1 -1
  98. package/dist/outbound-sessions.js +217 -17
  99. package/dist/outbound-sessions.js.map +1 -1
  100. package/dist/park-envelope.d.ts +95 -1
  101. package/dist/park-envelope.d.ts.map +1 -1
  102. package/dist/park-envelope.js +195 -4
  103. package/dist/park-envelope.js.map +1 -1
  104. package/dist/refusal-reasons.d.ts +72 -0
  105. package/dist/refusal-reasons.d.ts.map +1 -0
  106. package/dist/refusal-reasons.js +69 -0
  107. package/dist/refusal-reasons.js.map +1 -0
  108. package/dist/register-handler.d.ts.map +1 -1
  109. package/dist/register-handler.js +76 -0
  110. package/dist/register-handler.js.map +1 -1
  111. package/dist/registration-manager.d.ts.map +1 -1
  112. package/dist/registration-manager.js +20 -1
  113. package/dist/registration-manager.js.map +1 -1
  114. package/dist/relay-only.d.ts +133 -0
  115. package/dist/relay-only.d.ts.map +1 -0
  116. package/dist/relay-only.js +183 -0
  117. package/dist/relay-only.js.map +1 -0
  118. package/dist/restart-seal-resolver.d.ts.map +1 -1
  119. package/dist/restart-seal-resolver.js +6 -0
  120. package/dist/restart-seal-resolver.js.map +1 -1
  121. package/dist/retry-queue.d.ts +49 -2
  122. package/dist/retry-queue.d.ts.map +1 -1
  123. package/dist/retry-queue.js +55 -10
  124. package/dist/retry-queue.js.map +1 -1
  125. package/dist/roster-freshness.d.ts +169 -0
  126. package/dist/roster-freshness.d.ts.map +1 -0
  127. package/dist/roster-freshness.js +249 -0
  128. package/dist/roster-freshness.js.map +1 -0
  129. package/dist/seal-certificate-pull.d.ts +17 -1
  130. package/dist/seal-certificate-pull.d.ts.map +1 -1
  131. package/dist/seal-certificate-pull.js +37 -3
  132. package/dist/seal-certificate-pull.js.map +1 -1
  133. package/dist/seal-coordinator.d.ts +17 -1
  134. package/dist/seal-coordinator.d.ts.map +1 -1
  135. package/dist/seal-coordinator.js +71 -0
  136. package/dist/seal-coordinator.js.map +1 -1
  137. package/dist/seal-failure-store.d.ts +104 -0
  138. package/dist/seal-failure-store.d.ts.map +1 -0
  139. package/dist/seal-failure-store.js +126 -0
  140. package/dist/seal-failure-store.js.map +1 -0
  141. package/dist/seal-flows.d.ts.map +1 -1
  142. package/dist/seal-flows.js +35 -6
  143. package/dist/seal-flows.js.map +1 -1
  144. package/dist/seal-upgrade.d.ts +20 -5
  145. package/dist/seal-upgrade.d.ts.map +1 -1
  146. package/dist/seal-upgrade.js +28 -5
  147. package/dist/seal-upgrade.js.map +1 -1
  148. package/dist/session-assignment-parser.d.ts +41 -5
  149. package/dist/session-assignment-parser.d.ts.map +1 -1
  150. package/dist/session-assignment-parser.js +77 -14
  151. package/dist/session-assignment-parser.js.map +1 -1
  152. package/dist/session-ceremony.d.ts +35 -0
  153. package/dist/session-ceremony.d.ts.map +1 -1
  154. package/dist/session-ceremony.js +97 -0
  155. package/dist/session-ceremony.js.map +1 -1
  156. package/dist/session-connection-gater.d.ts +46 -7
  157. package/dist/session-connection-gater.d.ts.map +1 -1
  158. package/dist/session-connection-gater.js +163 -12
  159. package/dist/session-connection-gater.js.map +1 -1
  160. package/dist/session-content-handlers.d.ts +12 -0
  161. package/dist/session-content-handlers.d.ts.map +1 -1
  162. package/dist/session-content-handlers.js +149 -8
  163. package/dist/session-content-handlers.js.map +1 -1
  164. package/dist/session-node-manager.d.ts +653 -40
  165. package/dist/session-node-manager.d.ts.map +1 -1
  166. package/dist/session-node-manager.js +5938 -374
  167. package/dist/session-node-manager.js.map +1 -1
  168. package/dist/session-read-handlers.d.ts +27 -0
  169. package/dist/session-read-handlers.d.ts.map +1 -1
  170. package/dist/session-read-handlers.js +127 -6
  171. package/dist/session-read-handlers.js.map +1 -1
  172. package/dist/session-relay-client.d.ts +177 -2
  173. package/dist/session-relay-client.d.ts.map +1 -1
  174. package/dist/session-relay-client.js +564 -16
  175. package/dist/session-relay-client.js.map +1 -1
  176. package/dist/session-salt-agreement.d.ts +338 -0
  177. package/dist/session-salt-agreement.d.ts.map +1 -0
  178. package/dist/session-salt-agreement.js +478 -0
  179. package/dist/session-salt-agreement.js.map +1 -0
  180. package/dist/signal-submission.d.ts.map +1 -1
  181. package/dist/signal-submission.js +10 -1
  182. package/dist/signal-submission.js.map +1 -1
  183. package/dist/signaling-connect.d.ts +18 -1
  184. package/dist/signaling-connect.d.ts.map +1 -1
  185. package/dist/signaling-connect.js +206 -6
  186. package/dist/signaling-connect.js.map +1 -1
  187. package/dist/types.d.ts +75 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/types.js.map +1 -1
  190. package/dist/vocabulary.d.ts.map +1 -1
  191. package/dist/vocabulary.js +10 -0
  192. package/dist/vocabulary.js.map +1 -1
  193. package/dist/wire-content-hash.d.ts +68 -0
  194. package/dist/wire-content-hash.d.ts.map +1 -1
  195. package/dist/wire-content-hash.js +83 -0
  196. package/dist/wire-content-hash.js.map +1 -1
  197. package/package.json +5 -5
@@ -0,0 +1,185 @@
1
+ /**
2
+ * assignment-verify.ts — DOD-M15-ASSIGN-1.
3
+ *
4
+ * `session-assignment-parser.ts` shape-validates a session assignment. Its header said the
5
+ * signature "is verified downstream by the transport/session layer against the directory's pinned
6
+ * key" — and that site did not exist: `buildSessionEstablishmentTbs` was called to SIGN, and
7
+ * nowhere to verify. This module is the downstream the comment promised.
8
+ *
9
+ * SEPARATE FROM THE PARSER on purpose. Parsing answers "is this the right shape"; verifying answers
10
+ * "did the right party say it". Keeping them apart means the second cannot be quietly skipped by a
11
+ * caller that only wanted the first — which is how the gap existed.
12
+ */
13
+ // `verify` (plain Ed25519) is deliberately NOT imported: the single-key branch that used it was
14
+ // removed with the downgrade refusal (review F1). Verifying `directory_signature` against the
15
+ // `directory_pubkey` riding beside it in the same unsigned frame checks a key against itself, and
16
+ // having that call available is how a future edit reintroduces the bypass.
17
+ import { verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT } from "@cello-protocol/crypto";
18
+ import { buildSessionEstablishmentTbs, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
19
+ /**
20
+ * DOD-M15-ASSIGN-1 — verify a session assignment's signature before anything trusts it.
21
+ *
22
+ * REFUSES, never logs-and-continues. The whole finding here is a comment naming a verification the
23
+ * tree does not perform, so a check that ran and then let the session proceed anyway would be the
24
+ * same defect with more code. `DOD-M15-ASSIGN-1`'s receiver gate reads this assignment; gating on a
25
+ * document nobody verified relocates trust rather than closing it.
26
+ */
27
+ export async function verifyAssignmentSignature(assignment, persistence, logger, agentName, correlationId) {
28
+ // RECOMPUTED, not taken from the frame — and that is a strengthening rather than a workaround.
29
+ // `genesis_prev_root` is not on the wire at all: the directory derives it from
30
+ // (pubA, pubB, session_id, timestamp) and so must we. A value we derive cannot be chosen for us,
31
+ // so the TBS we verify against is anchored to the assignment's own participants and id.
32
+ const genesisPrevRoot = computeGenesisPrevRoot(assignment.participant_a.pubkey, assignment.participant_b.pubkey, assignment.session_id, assignment.session_timestamp);
33
+ const tbs = buildSessionEstablishmentTbs(assignment.session_id, assignment.participant_a.pubkey, assignment.participant_b.pubkey, genesisPrevRoot, assignment.session_timestamp, assignment.initiator_session_peer_id, assignment.initiator_session_addrs, assignment.counterparty_session_peer_id, assignment.counterparty_session_addrs, assignment.transport_mode);
34
+ /**
35
+ * LOAD THE REGISTRATION BEFORE BRANCHING ON `signature_type` — review F1, and this ordering is
36
+ * the entire fix.
37
+ *
38
+ * `signature_type` rides in the frame and is covered by no signature. The parser reads any value
39
+ * that is not the string "frost" — including an ABSENT field — as "single". So while this load
40
+ * lived inside the frost branch, a hostile directory disabled every check below by omitting one
41
+ * field: it put its own freshly-minted key in `directory_pubkey`, signed a TBS naming an
42
+ * impostor as the counterparty, and the single-key branch verified that signature against that
43
+ * same key and returned ok. The anti-circularity comparison, the threshold verify and the
44
+ * fail-closed were all simply stepped over.
45
+ *
46
+ * Reading the registration first closes it: an agent that HAS a threshold registration knows its
47
+ * assignments are FROST-signed, so a non-FROST one is a downgrade attempt and is refused by
48
+ * name — never quietly routed to a weaker check.
49
+ */
50
+ const reg = await persistence.loadRegistrationState();
51
+ if (!reg) {
52
+ // FAIL CLOSED. Without our own registration we cannot know whose quorum should have signed,
53
+ // so the signature is unverifiable — which is not the same as valid.
54
+ logger.warn("session.assignment.verify.no_identity", {
55
+ agentName, correlationId,
56
+ impact: "this agent has no persisted registration, so the signer of its own session assignment cannot be established; the session was refused rather than accepted unverified",
57
+ });
58
+ return {
59
+ ok: false,
60
+ reason: "assignment_unverifiable_no_registration",
61
+ guidance: "This agent has no registration on record, so the directory's session assignment cannot be checked against the key that should have signed it. Re-register with cello register-agent, then try again.",
62
+ };
63
+ }
64
+ if (assignment.signature_type !== "frost") {
65
+ /**
66
+ * THE DOWNGRADE REFUSAL. Checked against the producer, not assumed: the directory constructs
67
+ * `signature_type: "frost"` unconditionally at a single site (`directory-node.ts`), with no
68
+ * branch that can emit anything else. There is no legitimate producer of a single-key session
69
+ * assignment for a registered agent, so this is not a compatibility path being closed — it is
70
+ * a shape that only an attacker or a broken directory can send.
71
+ */
72
+ logger.error("session.assignment.signature_type_downgraded", {
73
+ agentName, correlationId,
74
+ offeredType: assignment.signature_type,
75
+ impact: "the assignment claimed a weaker signature type than this agent's registration can produce, which would have routed it to a check that verifies a key against itself; it was refused, and no session was established",
76
+ });
77
+ return {
78
+ ok: false,
79
+ reason: "assignment_signature_type_downgraded",
80
+ guidance: "The directory returned a session assignment that does not carry a threshold signature, though this agent is registered with one. Nothing was established and no message was sent. Retry the session; if it repeats, the directory you reached is not producing valid assignments and cello status will show which one that is.",
81
+ };
82
+ }
83
+ {
84
+ // THE ANTI-CIRCULARITY CHECK. `signer_pubkey` comes from the frame; `reg.primaryPubkey` comes
85
+ // from this machine's own registration. Comparing them is what makes verifying against
86
+ // `signer_pubkey` mean anything at all.
87
+ const expected = reg.primaryPubkey.toLowerCase();
88
+ const offered = Buffer.from(assignment.signer_pubkey).toString("hex").toLowerCase();
89
+ if (offered !== expected) {
90
+ logger.error("session.assignment.signer_mismatch", {
91
+ agentName, correlationId,
92
+ expectedPrefix: expected.slice(0, 16), offeredPrefix: offered.slice(0, 16),
93
+ impact: "the assignment was signed by a key that is not this agent's own threshold group key; it was refused, and no session was established",
94
+ });
95
+ return {
96
+ ok: false,
97
+ reason: "assignment_signer_not_this_agent",
98
+ guidance: "The directory returned a session assignment signed by a key that is not this agent's own. Nothing was established and no message was sent. This is refused rather than reported as an error because the observation cannot distinguish a directory fault from an attempt to broker a session on your behalf; cause undetermined.",
99
+ };
100
+ }
101
+ if (!verifyFrostSignature(assignment.directory_signature, tbs, CONTEXT_SESSION_ESTABLISHMENT, assignment.signer_pubkey)) {
102
+ logger.error("session.assignment.signature_invalid", {
103
+ agentName, correlationId, signatureType: "frost",
104
+ impact: "the assignment's threshold signature did not verify over its own contents; it was refused, and no session was established",
105
+ });
106
+ return {
107
+ ok: false,
108
+ reason: "assignment_signature_invalid",
109
+ guidance: "The directory's session assignment did not verify against the key that should have signed it. Nothing was established. Retry the session; if it repeats, the directory you reached is not producing valid assignments and cello status will show which one that is.",
110
+ };
111
+ }
112
+ return { ok: true };
113
+ }
114
+ }
115
+ /**
116
+ * DOD-M15-RESPONDER-VERIFY-1 — the RESPONDER's half, which until now did not exist.
117
+ *
118
+ * The initiator verifies its assignment against its own threshold group key (above). The responder
119
+ * did not verify at all: it logged `session.inbound.assignment.unverified` and proceeded, so every
120
+ * field it then acted on — the dialer it opens its receiver to, the `signer_pubkey` it persists as
121
+ * the seal trust anchor — was whatever the directory said.
122
+ *
123
+ * ─── Why this could not simply reuse the function above ────────────────────────────────────────
124
+ *
125
+ * That one compares `signer_pubkey` against the agent's OWN persisted `primaryPubkey`. The responder
126
+ * is not the signer — the assignment is signed by the INITIATOR's quorum — so it has no equivalent
127
+ * value, and verifying a frame's signature against a key from the same frame is circular: mint a
128
+ * key, sign with it, name it. That circularity is why the check was deferred rather than written.
129
+ *
130
+ * ─── What breaks the circle ────────────────────────────────────────────────────────────────────
131
+ *
132
+ * The TOFU pin. For a counterparty this agent has completed a session with, `expectedSignerHex` is
133
+ * what THIS daemon recorded then — a value no directory can retroactively change. Verifying against
134
+ * that is a real check: a compromised directory can produce a signature, but not one that verifies
135
+ * under a key it does not hold.
136
+ *
137
+ * ─── Two modes, and the weaker one is honest about being weaker ────────────────────────────────
138
+ *
139
+ * PINNED (`expectedSignerHex` given): the signature must verify under the PINNED key. Non-circular.
140
+ * This is the mode that matters, and it covers every repeat counterparty.
141
+ *
142
+ * UNPINNED (first contact): there is nothing independent to verify against, so this checks INTERNAL
143
+ * CONSISTENCY only — the signature verifies over the TBS recomputed from the assignment's own
144
+ * contents, under the key the frame names. That does NOT authenticate the directory, and it is not
145
+ * claimed to. What it does catch is a TAMPERED or garbage assignment — one whose fields were altered
146
+ * after signing, or whose signature is noise — which today reaches the seal path unchallenged and
147
+ * gets PINNED, poisoning every later session with that counterparty. Refusing it here is what stops
148
+ * a bad first contact becoming a permanent one.
149
+ */
150
+ export function verifyInboundAssignment(assignment, expectedSignerHex) {
151
+ if (assignment.signature_type !== "frost") {
152
+ return {
153
+ ok: false,
154
+ reason: "inbound_assignment_not_frost",
155
+ detail: `signature_type was ${String(assignment.signature_type)}; the directory produces frost for every session assignment`,
156
+ };
157
+ }
158
+ const signer = assignment.signer_pubkey;
159
+ if (!signer || signer.length !== 32) {
160
+ return { ok: false, reason: "inbound_assignment_no_signer", detail: "signer_pubkey missing or not 32 bytes" };
161
+ }
162
+ // PINNED beats named. When we hold an independent value, the frame's own claim about who signed is
163
+ // not consulted at all — using it would reintroduce the circularity the pin exists to break.
164
+ const verifyAgainst = expectedSignerHex !== null ? Buffer.from(expectedSignerHex, "hex") : signer;
165
+ if (expectedSignerHex !== null && Buffer.from(signer).toString("hex").toLowerCase() !== expectedSignerHex.toLowerCase()) {
166
+ return {
167
+ ok: false,
168
+ reason: "inbound_assignment_signer_not_pinned",
169
+ detail: "the assignment names a different signer than this agent recorded for this counterparty",
170
+ };
171
+ }
172
+ const genesisPrevRoot = computeGenesisPrevRoot(assignment.participant_a.pubkey, assignment.participant_b.pubkey, assignment.session_id, assignment.session_timestamp);
173
+ const tbs = buildSessionEstablishmentTbs(assignment.session_id, assignment.participant_a.pubkey, assignment.participant_b.pubkey, genesisPrevRoot, assignment.session_timestamp, assignment.initiator_session_peer_id, assignment.initiator_session_addrs, assignment.counterparty_session_peer_id, assignment.counterparty_session_addrs, assignment.transport_mode);
174
+ if (!verifyFrostSignature(assignment.directory_signature, tbs, CONTEXT_SESSION_ESTABLISHMENT, new Uint8Array(verifyAgainst))) {
175
+ return {
176
+ ok: false,
177
+ reason: "inbound_assignment_signature_invalid",
178
+ detail: expectedSignerHex !== null
179
+ ? "the signature did not verify under the key this agent recorded for this counterparty"
180
+ : "the signature did not verify over the assignment's own recomputed contents — the frame is tampered or malformed",
181
+ };
182
+ }
183
+ return { ok: true, mode: expectedSignerHex !== null ? "pinned" : "internal" };
184
+ }
185
+ //# sourceMappingURL=assignment-verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assignment-verify.js","sourceRoot":"","sources":["../src/assignment-verify.ts"],"names":[],"mappings":"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"}
@@ -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