@cello-protocol/daemon 0.0.180 → 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.
- package/dist/agent-id-migration.d.ts +9 -0
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +73 -1
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/assignment-verify.d.ts +62 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +185 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +340 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.js +91 -49
- package/dist/bin/cello-daemon.js.map +1 -1
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +20 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +351 -58
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts +2 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +65 -7
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +141 -10
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +13 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +928 -123
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-session-suspects.d.ts.map +1 -1
- package/dist/delivery-session-suspects.js +5 -0
- package/dist/delivery-session-suspects.js.map +1 -1
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +66 -3
- package/dist/directory-bootstrap.d.ts.map +1 -1
- package/dist/directory-bootstrap.js +133 -7
- package/dist/directory-bootstrap.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +22 -2
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +11 -3
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-frame-router.d.ts +14 -0
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +17 -0
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-gate.js +51 -8
- package/dist/document-gate.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +50 -6
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-inbound.d.ts +22 -0
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +54 -0
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-layer.d.ts +13 -0
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +1 -0
- package/dist/document-layer.js.map +1 -1
- package/dist/document-screen.d.ts +39 -0
- package/dist/document-screen.d.ts.map +1 -1
- package/dist/document-screen.js +64 -9
- package/dist/document-screen.js.map +1 -1
- package/dist/inbound-sessions.d.ts +31 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +276 -16
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/ipc-server.d.ts +5 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +19 -3
- package/dist/ipc-server.js.map +1 -1
- package/dist/manifest-validity.d.ts +151 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +15 -0
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +31 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +198 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +72 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +119 -4
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +72 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +69 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.js.map +1 -1
- package/dist/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +6 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/retry-queue.d.ts +23 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +37 -9
- package/dist/retry-queue.js.map +1 -1
- package/dist/roster-freshness.d.ts +169 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +249 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +17 -1
- package/dist/seal-certificate-pull.d.ts.map +1 -1
- package/dist/seal-certificate-pull.js +37 -3
- package/dist/seal-certificate-pull.js.map +1 -1
- package/dist/seal-coordinator.d.ts +17 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +71 -0
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-failure-store.d.ts +104 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +126 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +35 -6
- package/dist/seal-flows.js.map +1 -1
- package/dist/seal-upgrade.d.ts +20 -5
- package/dist/seal-upgrade.d.ts.map +1 -1
- package/dist/seal-upgrade.js +28 -5
- package/dist/seal-upgrade.js.map +1 -1
- package/dist/session-assignment-parser.d.ts +41 -5
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +77 -14
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +29 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +71 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-connection-gater.d.ts +46 -7
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +163 -12
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +110 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +407 -37
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3051 -239
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts +27 -0
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +112 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +61 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +170 -11
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +284 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +371 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js +10 -1
- package/dist/signal-submission.js.map +1 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +42 -5
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +56 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +71 -0
- package/dist/wire-content-hash.js.map +1 -1
- 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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"}
|
package/dist/bin/cello-daemon.js
CHANGED
|
@@ -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
|
-
|
|
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.
|