@cello-protocol/daemon 0.0.8 → 0.0.10

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 (64) hide show
  1. package/dist/agent-loader.d.ts +15 -18
  2. package/dist/agent-loader.d.ts.map +1 -1
  3. package/dist/agent-loader.js +24 -81
  4. package/dist/agent-loader.js.map +1 -1
  5. package/dist/bin/cello-daemon.js +5 -7
  6. package/dist/bin/cello-daemon.js.map +1 -1
  7. package/dist/daemon.d.ts.map +1 -1
  8. package/dist/daemon.js +153 -96
  9. package/dist/daemon.js.map +1 -1
  10. package/dist/db-identity-store.d.ts +97 -0
  11. package/dist/db-identity-store.d.ts.map +1 -0
  12. package/dist/db-identity-store.js +235 -0
  13. package/dist/db-identity-store.js.map +1 -0
  14. package/dist/identity-migration.d.ts +40 -0
  15. package/dist/identity-migration.d.ts.map +1 -0
  16. package/dist/identity-migration.js +455 -0
  17. package/dist/identity-migration.js.map +1 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/manifest-version-store-db.d.ts +24 -0
  23. package/dist/manifest-version-store-db.d.ts.map +1 -0
  24. package/dist/manifest-version-store-db.js +57 -0
  25. package/dist/manifest-version-store-db.js.map +1 -0
  26. package/dist/manifest-version-store.d.ts +3 -1
  27. package/dist/manifest-version-store.d.ts.map +1 -1
  28. package/dist/manifest-version-store.js +3 -1
  29. package/dist/manifest-version-store.js.map +1 -1
  30. package/dist/nonce-dedup.d.ts +2 -2
  31. package/dist/nonce-dedup.d.ts.map +1 -1
  32. package/dist/nonce-dedup.js.map +1 -1
  33. package/dist/registration-manager.d.ts.map +1 -1
  34. package/dist/registration-manager.js +78 -44
  35. package/dist/registration-manager.js.map +1 -1
  36. package/dist/retry-queue.d.ts +2 -3
  37. package/dist/retry-queue.d.ts.map +1 -1
  38. package/dist/retry-queue.js +8 -16
  39. package/dist/retry-queue.js.map +1 -1
  40. package/dist/seal-upgrade.d.ts +3 -1
  41. package/dist/seal-upgrade.d.ts.map +1 -1
  42. package/dist/seal-upgrade.js +1 -2
  43. package/dist/seal-upgrade.js.map +1 -1
  44. package/dist/session-ceremony.d.ts +8 -4
  45. package/dist/session-ceremony.d.ts.map +1 -1
  46. package/dist/session-ceremony.js +3 -7
  47. package/dist/session-ceremony.js.map +1 -1
  48. package/dist/session-node-manager.d.ts +10 -10
  49. package/dist/session-node-manager.d.ts.map +1 -1
  50. package/dist/session-node-manager.js +45 -45
  51. package/dist/session-node-manager.js.map +1 -1
  52. package/dist/sqlcipher-db.d.ts +87 -0
  53. package/dist/sqlcipher-db.d.ts.map +1 -0
  54. package/dist/sqlcipher-db.js +259 -0
  55. package/dist/sqlcipher-db.js.map +1 -0
  56. package/package.json +5 -4
  57. package/dist/manifest-version-store-file.d.ts +0 -18
  58. package/dist/manifest-version-store-file.d.ts.map +0 -1
  59. package/dist/manifest-version-store-file.js +0 -40
  60. package/dist/manifest-version-store-file.js.map +0 -1
  61. package/dist/transcript-cipher.d.ts +0 -31
  62. package/dist/transcript-cipher.d.ts.map +0 -1
  63. package/dist/transcript-cipher.js +0 -74
  64. package/dist/transcript-cipher.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -40,8 +40,9 @@ import { createNode, SignalingManager } from "@cello-protocol/transport";
40
40
  import { createSignalingConnect } from "./signaling-connect.js";
41
41
  import { RegistrationManager } from "./registration-manager.js";
42
42
  import { DaemonRegistrationContext } from "./registration-context.js";
43
- import { FileRegistrationPersistence } from "./registration-persistence.js";
44
- import { verify as ed25519Verify, sealToRecipient } from "@cello-protocol/crypto";
43
+ import { DbRegistrationPersistence, DbIdentityStore } from "./db-identity-store.js";
44
+ import { DbManifestVersionStore } from "./manifest-version-store-db.js";
45
+ import { verify as ed25519Verify, sealToRecipient, generateKLocalSeed, InMemoryKeyProvider } from "@cello-protocol/crypto";
45
46
  import { attemptSealUpgrade as attemptSealUpgradeImpl, verifyUpgradeConfirmedCert } from "./seal-upgrade.js";
46
47
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
47
48
  // at the send point here (the receive point lives in the transport content decode).
@@ -169,7 +170,7 @@ class ProductionSessionNodeFactory {
169
170
  }
170
171
  }
171
172
  export async function startDaemon(config) {
172
- const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore, manifestPollScheduler, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
173
+ const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
173
174
  // CELLO-M7-TRANSPORT-001: composition-root selection of the transport selector.
174
175
  // Driven by CELLO_ENV; fails fast at startup (here, not at first session) when a
175
176
  // production environment is missing the required transport dialer (AC-010).
@@ -183,6 +184,31 @@ export async function startDaemon(config) {
183
184
  env: celloEnv,
184
185
  selector: isProductionVariant(celloEnv) ? "real" : "stub",
185
186
  });
187
+ // ADV-006 + ADV-008 (hoisted — code-review MED): pure config validation runs BEFORE any disk side
188
+ // effect (lock, the irreversible one-time migration, DB open). A misconfigured daemon must fail
189
+ // before mutating state. If manifestProvider is set, manifestRootKeys + a positive threshold are
190
+ // required.
191
+ if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
192
+ throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
193
+ }
194
+ // ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
195
+ // (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
196
+ // precede the manifest verification below because the manifest version is now stored in the
197
+ // encrypted DB (AC-008), not a manifest-version.json file. ──
198
+ await mkdir(celloDir, { recursive: true });
199
+ await mkdir(dirname(socketPath), { recursive: true });
200
+ await acquireLock(lockFilePath, { pid: process.pid, socketPath, version });
201
+ const sessionNodeManager = new SessionNodeManager({
202
+ factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
203
+ logger,
204
+ dbPath: join(celloDir, "sessions.db"),
205
+ contentTtfMs: config.contentTtfMs,
206
+ autoNatProbers: () => [],
207
+ });
208
+ await sessionNodeManager.initialize();
209
+ // AC-008: the manifest version store is DB-backed by default (encrypted manifest_state table). A
210
+ // test may inject an override (e.g. InMemoryManifestVersionStore) via config.
211
+ const manifestVersionStore = injectedManifestVersionStore ?? new DbManifestVersionStore(sessionNodeManager.getDb(), logger);
186
212
  // M7-MANIFEST-002: Load and verify consortium manifest BEFORE any directory connection.
187
213
  //
188
214
  // Pseudocode for manifest loading:
@@ -197,12 +223,6 @@ export async function startDaemon(config) {
197
223
  // M7 Keystone: the version of the verified manifest, surfaced in ConnectResult.
198
224
  // Stays 0 when no manifestProvider is configured (the M6 backward-compat path).
199
225
  let verifiedManifestVersion = 0;
200
- // ADV-006 + ADV-008: If manifestProvider is set, manifestRootKeys and a positive
201
- // manifestThreshold are required. Fail loudly on misconfiguration rather than
202
- // silently proceeding unverified.
203
- if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
204
- throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
205
- }
206
226
  if (manifestProvider && manifestRootKeys && manifestThreshold !== undefined) {
207
227
  try {
208
228
  const manifest = await manifestProvider.loadAndVerify(manifestRootKeys, manifestThreshold);
@@ -223,26 +243,16 @@ export async function startDaemon(config) {
223
243
  });
224
244
  }
225
245
  else {
226
- // Check version monotonicity if version store is provided
227
- if (manifestVersionStore) {
228
- const lastSeen = await manifestVersionStore.getLastSeenVersion();
229
- if (lastSeen !== null && manifest.version < lastSeen) {
230
- logger.error("directory.auth.manifest.version.rollback", {
231
- manifestVersion: manifest.version,
232
- lastSeenVersion: lastSeen,
233
- });
234
- }
235
- else {
236
- await manifestVersionStore.persistVersion(manifest.version);
237
- manifestVerified = true;
238
- verifiedManifestVersion = manifest.version;
239
- logger.info("directory.auth.manifest.verified", {
240
- manifestVersion: manifest.version,
241
- signerCount: manifest.signatures.length,
242
- });
243
- }
246
+ // Anti-rollback monotonicity (manifestVersionStore is always present now — DB-backed default).
247
+ const lastSeen = await manifestVersionStore.getLastSeenVersion();
248
+ if (lastSeen !== null && manifest.version < lastSeen) {
249
+ logger.error("directory.auth.manifest.version.rollback", {
250
+ manifestVersion: manifest.version,
251
+ lastSeenVersion: lastSeen,
252
+ });
244
253
  }
245
254
  else {
255
+ await manifestVersionStore.persistVersion(manifest.version);
246
256
  manifestVerified = true;
247
257
  verifiedManifestVersion = manifest.version;
248
258
  logger.info("directory.auth.manifest.verified", {
@@ -262,21 +272,24 @@ export async function startDaemon(config) {
262
272
  // failed, the daemon must refuse to proceed. Operators who configure
263
273
  // manifestProvider have opted into manifest enforcement.
264
274
  if (manifestProvider && !manifestVerified) {
275
+ // code-review MED: this refuse now runs AFTER the DB is open + the lock is held (the DB had to
276
+ // open for the anti-rollback check). Release both before rethrowing so an in-process caller does
277
+ // not leak the DB handle / lock (in production the process exits, but be tidy).
278
+ try {
279
+ sessionNodeManager.getDb().close();
280
+ }
281
+ catch { /* ignore */ }
282
+ await removeLock(lockFilePath, logger).catch(() => { });
265
283
  throw new Error("Manifest verification failed. The daemon cannot start with an unverified manifest when manifestProvider is configured. " +
266
284
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
267
285
  }
268
- // Ensure the cello directory exists
269
- await mkdir(celloDir, { recursive: true });
270
- // Ensure the socket parent directory exists
271
- await mkdir(dirname(socketPath), { recursive: true });
272
- // Load agent identities
273
- const { loaded: loadedAgents, failed: failedAgents } = await loadAgents(celloDir, logger);
274
- // Acquire lock file
275
- await acquireLock(lockFilePath, {
276
- pid: process.pid,
277
- socketPath,
278
- version,
279
- });
286
+ // Load agent identities from the encrypted `agents` table (PERSIST-002 AC-007 — one path).
287
+ // (The encrypted store + lock were established above, before manifest verification.)
288
+ const { loaded: loadedAgents, failed: failedAgents } = await loadAgents(sessionNodeManager.getDb(), logger);
289
+ // PERSIST-002: per-agent DB-backed identity persistence. The registration handler and the
290
+ // ceremony/seal signer-reconstruction load the FROST share (and persist the identity) through this
291
+ // seam the encrypted `agents` row, never a flat file.
292
+ const getPersistence = (agentName) => new DbRegistrationPersistence({ db: sessionNodeManager.getDb(), agentName, logger });
280
293
  // Build agent state (all start in 'registered' state — no auto-start)
281
294
  const agents = [
282
295
  ...loadedAgents.map((a) => ({
@@ -335,7 +348,11 @@ export async function startDaemon(config) {
335
348
  // L4: sort by name so the "primary" agent is STABLE across restarts — readdir
336
349
  // order (agent-loader) is platform-dependent and unsorted, which would otherwise
337
350
  // let the authenticating identity change between daemon restarts.
338
- const primaryAgent = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
351
+ // CELLO-M7-ONBOARD-001: `primaryAgent` is mutable so the FIRST agent created at runtime
352
+ // (cello_create_agent on a fresh install) can be elected as the keystone identity — otherwise the
353
+ // directory door stays `reconnecting` forever until a restart. getAuthIdentity reads it on every
354
+ // reconnect attempt, so electing it makes the already-running reconnect loop authenticate + connect.
355
+ let primaryAgent = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
339
356
  const getAuthIdentity = () => {
340
357
  if (!primaryAgent)
341
358
  return null;
@@ -446,7 +463,7 @@ export async function startDaemon(config) {
446
463
  // registration routing). Unregistered implicitly when the manager is stopped.
447
464
  wireSessionCeremonyHandler({
448
465
  agentName,
449
- agentDir: join(celloDir, "agents", agentName),
466
+ persistence: getPersistence(agentName),
450
467
  agentPubkeyHex,
451
468
  getNode: entry.getNode,
452
469
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
@@ -456,7 +473,7 @@ export async function startDaemon(config) {
456
473
  // DOD-SPINE-7: coordinate the SEAL FROST ceremony on this agent's stream too.
457
474
  wireSealCeremonyHandler({
458
475
  agentName,
459
- agentDir: join(celloDir, "agents", agentName),
476
+ persistence: getPersistence(agentName),
460
477
  agentPubkeyHex,
461
478
  getNode: entry.getNode,
462
479
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
@@ -505,57 +522,56 @@ export async function startDaemon(config) {
505
522
  await entry.signaling.stop();
506
523
  logger.info("agent.signaling.dropped", { agentName });
507
524
  }
508
- // DOD-SPINE-5: the PRIMARY agent registers + initiates over the keystone signaling
509
- // stream (not a per-agent one), so wire its ceremony_request handler on the keystone
510
- // manager too — otherwise a primary-agent initiator's session ceremony would time out.
511
- if (primaryAgent) {
525
+ // DOD-SPINE-5/7: the PRIMARY agent registers + initiates + coordinates the session/seal FROST
526
+ // ceremonies over the keystone signaling stream (not a per-agent one), so its ceremony/seal/offer
527
+ // handlers must be wired on the keystone manager — otherwise a primary-agent initiator's session
528
+ // ceremony would time out. CELLO-M7-ONBOARD-001: extracted into a function so it can also be wired
529
+ // when the first agent is elected at runtime (cello_create_agent), not only at startup.
530
+ function wireKeystonePrimary(agent) {
512
531
  wireSessionCeremonyHandler({
513
- agentName: primaryAgent.name,
514
- agentDir: join(celloDir, "agents", primaryAgent.name),
515
- agentPubkeyHex: primaryAgent.pubkey,
532
+ agentName: agent.name,
533
+ persistence: getPersistence(agent.name),
534
+ agentPubkeyHex: agent.pubkey,
516
535
  getNode: getDirectoryNode,
517
536
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
518
537
  signaling: signalingManager,
519
538
  logger,
520
539
  });
521
- // DOD-SPINE-7: the primary agent also coordinates the SEAL FROST ceremony on the keystone.
522
540
  wireSealCeremonyHandler({
523
- agentName: primaryAgent.name,
524
- agentDir: join(celloDir, "agents", primaryAgent.name),
525
- agentPubkeyHex: primaryAgent.pubkey,
541
+ agentName: agent.name,
542
+ persistence: getPersistence(agent.name),
543
+ agentPubkeyHex: agent.pubkey,
526
544
  getNode: getDirectoryNode,
527
545
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
528
546
  signaling: signalingManager,
529
547
  logger,
530
548
  });
531
549
  wireSessionOfferHandler({
532
- agentName: primaryAgent.name,
533
- getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(primaryAgent.name),
550
+ agentName: agent.name,
551
+ getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
534
552
  signaling: signalingManager,
535
553
  logger,
536
554
  });
555
+ // The primary closes its OWN sessions over the keystone stream, so the directory routes its
556
+ // session_sealed / seal_unilateral_confirmed / seal_unilateral_notification frames there. These
557
+ // seal-completion listeners MUST be registered for the keystone primary (startup OR runtime
558
+ // election) — otherwise the close waiter never resolves and the seal silently never finalizes
559
+ // (review HIGH: previously these were a separate startup-only block, skipped on a fresh install).
560
+ // The register* functions are hoisted declarations within startDaemon, so callable here.
561
+ registerSessionSealedListener(signalingManager, agent.name, agent.pubkey);
562
+ registerUnilateralConfirmedListener(signalingManager, agent.name, agent.pubkey);
563
+ registerUnilateralUpgradeListener(signalingManager, agent.name, agent.pubkey);
537
564
  }
565
+ if (primaryAgent)
566
+ wireKeystonePrimary(primaryAgent);
538
567
  // Per-connection state: tracks which agent is "current" for each IPC connection.
539
568
  // Key = connectionId (assigned by IPC server), Value = current agent name or null.
540
569
  const perConnectionState = new Map();
541
570
  // Set of agents currently in "online" state (transitioned via cello_start_agent)
542
571
  const onlineAgents = new Set();
543
- // Initialize SessionNodeManager (DAEMON-002: composition root AC-011).
544
- // This runs before the IPC socket opens so:
545
- // 1. The standing receiver is ready before any cello_await_session call.
546
- // 2. Interrupted session detection runs before any tool call can race.
547
- const sessionNodeManager = new SessionNodeManager({
548
- factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
549
- logger,
550
- dbPath: join(celloDir, "sessions.db"),
551
- contentTtfMs: config.contentTtfMs,
552
- // CELLO-M7-TRANSPORT-001: directory-node AutoNAT probers (SI-002). The
553
- // directory connection (SIGNAL-001) is not yet wired into the daemon, so the
554
- // prober set is empty — AutoNAT cannot run and the standing receiver reports
555
- // the conservative default + transport.autonat.unavailable (AC-004/DB-001).
556
- autoNatProbers: () => [],
557
- });
558
- await sessionNodeManager.initialize();
572
+ // SessionNodeManager was constructed + initialized at the top of startDaemon (PERSIST-002 — the
573
+ // encrypted store must open before agents load from the `agents` table). Its standing receiver +
574
+ // interrupted-session detection are already ready here, before the IPC socket opens.
559
575
  // CELLO-M7-TRANSPORT-001: the daemon's runtime AutoNAT service is the one
560
576
  // wrapping the standing receiver node (it emits transport.autonat.result /
561
577
  // transport.autonat.unavailable and its dialability drives the SessionAssignment
@@ -687,7 +703,7 @@ export async function startDaemon(config) {
687
703
  // DAEMON-003: Initialize RetryQueue and NonceDedupStore (AC-008).
688
704
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
689
705
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
690
- const retryQueue = new RetryQueue(sessionNodeManager.getDb(), logger, sessionNodeManager.getTranscriptCipher());
706
+ const retryQueue = new RetryQueue(sessionNodeManager.getDb(), logger);
691
707
  retryQueue.loadFromDb();
692
708
  // CELLO-M7-MSG-001 (AC-001/AC-003/AC-019): wire the awaiting-ACK lifecycle's durable
693
709
  // side effects to the retry_queue. A `persisted` delivery ACK clears the durable
@@ -925,6 +941,57 @@ export async function startDaemon(config) {
925
941
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
926
942
  return { ok: true };
927
943
  });
944
+ // ─── PERSIST-002 (AC-004): cello_create_agent handler ───
945
+ // The explicit agent-creation path: generate a fresh K_local seed, write it as an `agents` row in
946
+ // the encrypted DB (NO key file), and wire the agent into the live daemon so it can be registered
947
+ // and used WITHOUT a restart. Creation is explicit — cello_start_agent never auto-creates on a typo.
948
+ handlers.set("cello_create_agent", async (params, _connectionId) => {
949
+ const name = params?.name;
950
+ if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
951
+ return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
952
+ }
953
+ const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
954
+ if (store.hasAgent(name) || agents.some((a) => a.name === name)) {
955
+ return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
956
+ }
957
+ let pubkeyHex;
958
+ try {
959
+ const seed = generateKLocalSeed();
960
+ const keyProvider = new InMemoryKeyProvider(seed);
961
+ pubkeyHex = Buffer.from(await keyProvider.getPublicKey()).toString("hex");
962
+ // SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey.
963
+ store.createAgent(name, seed, pubkeyHex);
964
+ // Runtime-add: make the agent immediately registrable/usable (the register handler resolves
965
+ // identity from keyProviders/loadedAgents; per-agent signaling is created lazily on register).
966
+ keyProviders.set(name, keyProvider);
967
+ const loaded = { name, pubkey: pubkeyHex, keyProvider };
968
+ loadedAgents.push(loaded);
969
+ agents.push({ name, state: "registered", pubkey: pubkeyHex });
970
+ // CELLO-M7-ONBOARD-001: on a FRESH install the daemon started with no agents, so there was no
971
+ // keystone identity and the directory door is stuck `reconnecting`. Elect this first agent as the
972
+ // keystone primary + wire its ceremony/seal handlers; the running reconnect loop then
973
+ // authenticates with it (getAuthIdentity now returns it) and connects on its next attempt — no
974
+ // restart needed. Only the FIRST agent is elected (loadedAgents is empty here, so it is also the
975
+ // name-sort min). NOTE (accepted LOW, review): if the operator then creates a lexicographically
976
+ // SMALLER second agent before any restart, the keystone primary stays this one until the next
977
+ // restart, where it re-derives to sort[0] — benign (each agent authenticates its own stream; the
978
+ // demoted agent gets fully wired via the per-agent path on restart), and not worth runtime
979
+ // re-election (un-wire/re-wire) churn.
980
+ if (!primaryAgent) {
981
+ primaryAgent = loaded;
982
+ wireKeystonePrimary(loaded);
983
+ logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
984
+ }
985
+ }
986
+ catch (err) {
987
+ logger.error("persist.identity.persist.failed", { agentName: name, error: err instanceof Error ? err.message : String(err) });
988
+ return { ok: false, reason: "agent_create_failed", guidance: "Could not create the agent. Check the daemon log and that the CELLO directory is writable, then retry." };
989
+ }
990
+ // Creation is not an online/offline transition — the agent appears (cello_list_agents) but is
991
+ // not online until cello_start_agent. Just record it.
992
+ logger.info("agent.created", { agentName: name, agentPubkey: pubkeyHex });
993
+ return { ok: true, name, pubkey: pubkeyHex };
994
+ });
928
995
  // ─── MCP-001: cello_stop_agent handler ───
929
996
  handlers.set("cello_stop_agent", async (params, _connectionId) => {
930
997
  const name = params?.name;
@@ -1028,7 +1095,7 @@ export async function startDaemon(config) {
1028
1095
  }
1029
1096
  const keyProvider = keyProviders.get(name);
1030
1097
  if (!keyProvider) {
1031
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' has no local K_local key loaded. Its key must exist at ~/.cello/agents/${name}/key before registration create it and restart the daemon, then retry cello_register.` };
1098
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it first with cello_create_agent('${name}') (or 'cello create-agent ${name}'), then retry cello_register.` };
1032
1099
  }
1033
1100
  if (!directoryEndpointResolver) {
1034
1101
  return { ok: false, reason: "directory_unreachable", guidance: "The daemon has no directory endpoint resolver configured, so it cannot reach the directory to register." };
@@ -1074,7 +1141,7 @@ export async function startDaemon(config) {
1074
1141
  guidance: `Agent '${name}' could not establish its directory signaling stream within 10s. Check CELLO_DIRECTORY_URL and that the directory is reachable, then retry cello_register.`,
1075
1142
  };
1076
1143
  }
1077
- const persistence = new FileRegistrationPersistence({ agentDir: join(celloDir, "agents", name), logger });
1144
+ const persistence = getPersistence(name);
1078
1145
  const ctx = new DaemonRegistrationContext({
1079
1146
  signaling: agentSignaling,
1080
1147
  getDirectoryNode: agentGetNode,
@@ -1092,6 +1159,10 @@ export async function startDaemon(config) {
1092
1159
  await dropAgentSignaling(name);
1093
1160
  return { ok: false, reason: result.error, guidance: registrationGuidance(result.error) };
1094
1161
  }
1162
+ // PERSIST-002 (AC-013): the identity row (K_local + share + ML-DSA + registration) is durably
1163
+ // committed at this point (RegistrationManager awaits the persist before returning success).
1164
+ // SI-001: never log a secret — only the agent name + PUBLIC key.
1165
+ logger.info("persist.identity.persisted", { agentName: name, agentPubkey: agentPubkeyHex });
1095
1166
  // Capture-now-or-lose-it: persist the agent→user link (using it is future
1096
1167
  // trust-layer work). L1: the agent is already registered at this point —
1097
1168
  // a link-write failure must NOT be reported as a registration failure.
@@ -1168,7 +1239,7 @@ export async function startDaemon(config) {
1168
1239
  return;
1169
1240
  }
1170
1241
  const record = sessionNodeManager.getSessionRecord(agentName, sidHex);
1171
- const verdict = await verifyBilateralSealCertificate({ agentDir: join(celloDir, "agents", agentName), agentPubkeyHex, logger, counterpartyPrimaryHex: record?.counterparty_primary_pubkey ?? null }, {
1242
+ const verdict = await verifyBilateralSealCertificate({ persistence: getPersistence(agentName), agentPubkeyHex, logger, counterpartyPrimaryHex: record?.counterparty_primary_pubkey ?? null }, {
1172
1243
  sessionId: sessionIdBytes,
1173
1244
  sealedRoot: sealedRootBytes,
1174
1245
  leafCount,
@@ -1318,7 +1389,7 @@ export async function startDaemon(config) {
1318
1389
  waiter({ ok: false, reason: "malformed_certificate" });
1319
1390
  return;
1320
1391
  }
1321
- const result = await verifyUnilateralCertificate({ agentDir: join(celloDir, "agents", agentName), agentPubkeyHex, logger }, { sessionId, sealedRoot, leafCount, closeTimestamp: closeTs, frostSignature: frostSig, signatureType: sigType });
1392
+ const result = await verifyUnilateralCertificate({ persistence: getPersistence(agentName), agentPubkeyHex, logger }, { sessionId, sealedRoot, leafCount, closeTimestamp: closeTs, frostSignature: frostSig, signatureType: sigType });
1322
1393
  pendingUnilateralWaiters.delete(sidHex);
1323
1394
  if (!result.ok) {
1324
1395
  // SI-003: do NOT mark sealed when the certificate signature does not verify.
@@ -1374,7 +1445,7 @@ export async function startDaemon(config) {
1374
1445
  // ONLY on ok. Never trust the directory's "bilateral" claim.
1375
1446
  async function verifyAndApplyUpgradeConfirmed(agentName, agentPubkeyHex, sidHex, frame) {
1376
1447
  const result = await verifyUpgradeConfirmedCert({
1377
- logger, agentName, agentPubkeyHex, celloDir,
1448
+ logger, agentName, agentPubkeyHex, persistence: getPersistence(agentName),
1378
1449
  getCounterpartyHex: (a, s) => sessionNodeManager.getSessionRecord(a, s)?.counterparty_pubkey ?? null,
1379
1450
  }, sidHex, frame);
1380
1451
  if (!result.ok)
@@ -2564,25 +2635,11 @@ export async function startDaemon(config) {
2564
2635
  handleInboundSessionAssignment(frame);
2565
2636
  });
2566
2637
  // M7 DOD-SPINE-7: session_sealed listener. The directory delivers this over the SESSION-OWNING
2567
- // agent's signaling stream after the relay-mediated bilateral seal notarizes. Registered on the
2568
- // keystone (primary agent) here AND per-agent in getAgentSignaling — for a non-primary agent the
2569
- // directory routes session_sealed to its per-agent stream, so a keystone-only listener would
2570
- // leave that agent's close waiter unresolved (reviewer finding). Resolve the close waiter with
2571
- // the sealed_root and mark the session sealed. Guarded on primaryAgent: the keystone listener now
2572
- // needs the primary agent's name/pubkey to verify the seal signature (legibility-TBS-binding), and
2573
- // with no agents there are no keystone sessions to seal anyway.
2574
- if (primaryAgent) {
2575
- registerSessionSealedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2576
- // SESSION-002: the keystone counterpart for the unilateral certificate listener — the
2577
- // primary agent closes over the keystone stream, so the directory routes its
2578
- // seal_unilateral_confirmed there (mirrors the session_sealed keystone listener above).
2579
- registerUnilateralConfirmedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2580
- // DOD-UP-1: the keystone counterpart for the absent-party upgrade listener. The directory
2581
- // PUSHES the queued seal_unilateral_notification during the keystone's auth/reconnect drain —
2582
- // BEFORE any cello_start_agent runs — so the handler MUST be registered here at startup, not only
2583
- // in startAgent, or B (the returning absent party) would miss its own ratification trigger.
2584
- registerUnilateralUpgradeListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2585
- }
2638
+ // CELLO-M7-ONBOARD-001: the keystone primary's seal-completion listeners (session_sealed /
2639
+ // seal_unilateral_confirmed / seal_unilateral_notification) are now registered inside
2640
+ // wireKeystonePrimary at startup for a loaded primary AND at runtime when the first agent is
2641
+ // elected (cello_create_agent on a fresh install). They must NOT be a separate startup-only block,
2642
+ // or a fresh-install primary's seals would silently never finalize (review HIGH).
2586
2643
  // cello_await_session — the counterparty's blocking pull for the next inbound session.
2587
2644
  // Returns immediately if one is already queued for the current agent (FIFO), otherwise
2588
2645
  // blocks until one arrives or timeout_ms elapses. Response shape matches the established