@cello-protocol/daemon 0.0.11 → 0.0.13

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/daemon.js CHANGED
@@ -53,6 +53,8 @@ import { parseSessionAssignment, sessionRequestErrorReason } from "./session-ass
53
53
  import { wireSessionCeremonyHandler, wireSessionOfferHandler, wireSealCeremonyHandler, verifyUnilateralCertificate, verifyBilateralSealCertificate } from "./session-ceremony.js";
54
54
  import { reDeriveFrontiers, findInflatedFrontier } from "./seal-frontier-verify.js";
55
55
  import { LocalAutoNatStub } from "@cello-protocol/transport";
56
+ import { startHttpManifestPoll } from "./http-manifest-poll.js";
57
+ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
56
58
  /**
57
59
  * M7-SESSION-001 (H-1): canonical byte encoding of a SEAL-INTERRUPTED leaf for
58
60
  * Ed25519 signing/verification. Field order is fixed and deterministic. Both the
@@ -170,7 +172,7 @@ class ProductionSessionNodeFactory {
170
172
  }
171
173
  }
172
174
  export async function startDaemon(config) {
173
- const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
175
+ const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
174
176
  // CELLO-M7-TRANSPORT-001: composition-root selection of the transport selector.
175
177
  // Driven by CELLO_ENV; fails fast at startup (here, not at first session) when a
176
178
  // production environment is missing the required transport dialer (AC-010).
@@ -283,6 +285,24 @@ export async function startDaemon(config) {
283
285
  throw new Error("Manifest verification failed. The daemon cannot start with an unverified manifest when manifestProvider is configured. " +
284
286
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
285
287
  }
288
+ // CELLO-M7-CONN-001 (DOD-CONN-3): start the daemon-level HTTP manifest poll. It fetches
289
+ // ${directoryHttpUrl}/manifest, verifies the threshold signature against the locally-pinned
290
+ // root keys, applies anti-rollback + expiry, and adopts a newer manifest — independent of any
291
+ // agent identity, running even with ZERO agents (the deleted keystone could not). Gated on the
292
+ // same manifest deps as the old signaling poll; off on the M6 backward-compat path (no scheduler).
293
+ let stopHttpManifestPoll;
294
+ if (manifestPollScheduler && manifestProvider && manifestRootKeys && manifestRootKeys.length > 0 && manifestThreshold && manifestThreshold >= 1) {
295
+ stopHttpManifestPoll = startHttpManifestPoll({
296
+ scheduler: manifestPollScheduler,
297
+ directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
298
+ manifestProvider,
299
+ manifestVersionStore,
300
+ rootKeys: manifestRootKeys,
301
+ threshold: manifestThreshold,
302
+ logger,
303
+ mintCorrelationId: () => randomUUID(),
304
+ });
305
+ }
286
306
  // Load agent identities from the encrypted `agents` table (PERSIST-002 AC-007 — one path).
287
307
  // (The encrypted store + lock were established above, before manifest verification.)
288
308
  const { loaded: loadedAgents, failed: failedAgents } = await loadAgents(sessionNodeManager.getDb(), logger);
@@ -336,111 +356,63 @@ export async function startDaemon(config) {
336
356
  const defaultConnect = async () => {
337
357
  throw new Error("directory_signaling_not_configured");
338
358
  };
339
- // M7 Keystone (Part 1): resolve the agent identity that authenticates the
340
- // directory signaling stream. The daemon's directory-facing node is one per
341
- // daemon, so the keystone authenticates as the PRIMARY agent (first successfully
342
- // loaded). Returns null when no agent is registered yet connect() throws
343
- // no_agent_identity and the SignalingManager stays reconnecting until one exists
344
- // (registration, Action 2, brings the first identity).
359
+ // CELLO-M7-CONN-001 (DOD-CONN-1): the keystone is DELETED. There is no shared directory
360
+ // connection borrowing the "primary" agent's identity. In PRODUCTION every agent operates its
361
+ // OWN directory signaling connection authenticated as itself (getAgentSignaling / signalingFor);
362
+ // removing any agent tears down only that agent's own connection, so the daemon never holds a
363
+ // connection authenticated as a removed agent (the Demo1 stranding bug). A single SHARED manager
364
+ // exists ONLY for the in-process test / backward-compat path (a single injected signalingConnect,
365
+ // no per-agent isolation) — in production it is undefined.
345
366
  //
346
- // NOTE (multi-agent, Action 2+): per-agent directory operations under distinct
347
- // identities are out of keystone scope. This establishes the directory door.
348
- // L4: sort by name so the "primary" agent is STABLE across restarts — readdir
349
- // order (agent-loader) is platform-dependent and unsorted, which would otherwise
350
- // let the authenticating identity change between daemon restarts.
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];
356
- const getAuthIdentity = () => {
357
- if (!primaryAgent)
358
- return null;
359
- return { keyProvider: primaryAgent.keyProvider, pubkeyHex: primaryAgent.pubkey };
360
- };
361
- // Production builds signalingConnect from the bootstrap resolver + agent identity.
362
- // Tests inject signalingConnect directly (takes precedence). Neither → defaultConnect
363
- // (DAEMON-001 backward-compat). challengeVerifier is left to the caller: when absent,
364
- // step-6 directory verification is skipped — the M6 path that connected and ran the
365
- // full DKG/seal pipeline.
366
- // M7 Action 2: the daemon holds a reference to the live directory-facing node so
367
- // registration's FROST DKG (NetworkDirectoryNode) — and future ceremonies/seal — can
368
- // open streams to the directory on the SAME node. createSignalingConnect sets it via
369
- // publishNode on a successful connect and clears it (null) when the stream closes.
370
- // Consumers MUST gate use on signalingManager.status === "connected".
367
+ // M7 Action 2: the daemon holds a reference to a live directory-facing node so registration's
368
+ // FROST DKG can open streams on the SAME node. In production each per-agent manager publishes its
369
+ // OWN node (getAgentSignaling); this shared ref is the test-path node only.
371
370
  let directoryNode = null;
372
371
  const getDirectoryNode = () => directoryNode;
373
- const resolvedConnect = signalingConnect ??
374
- (directoryEndpointResolver
375
- ? createSignalingConnect({
376
- getDirectoryEndpoint: directoryEndpointResolver,
377
- getAuthIdentity,
378
- logger,
379
- challengeVerifier,
380
- getManifestVersion: () => verifiedManifestVersion,
381
- publishNode: (n) => {
382
- directoryNode = n;
383
- },
384
- })
385
- : defaultConnect);
386
- // H1: a long-running daemon must ride out directory outages — notably the
387
- // 25-30 min multi-region directory deploy. The transport default of 10 reconnect
388
- // attempts (~5 min with default backoff) transitions the manager to terminal
389
- // "lost" mid-deploy, with no public way to re-enter the loop — the daemon would
390
- // never recover without a cello logout/login. Use an effectively-unbounded attempt
391
- // budget with a capped backoff so it keeps retrying and reconnects within
392
- // ~maxBackoffMs of the directory returning. (Availability is a first-class invariant.)
393
- //
394
- // L3: challengeVerifier is NOT passed here — the dialer (createSignalingConnect)
395
- // performs step-6 verification itself, matching #doOpen. The manager's copy would
396
- // be dead (processStep5Frame is only invoked inside connect()).
397
- const signalingManager = new SignalingManager({
398
- connect: resolvedConnect,
399
- logger,
400
- maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
401
- maxBackoffMs: 30_000,
402
- // DOD-AUTH-2: the keystone manager (primary agent's directory door) carries the
403
- // manifest-poll deps so it re-polls the directory on its live stream and adopts a
404
- // newer signed manifest. The SAME shared manifestProvider instance the startup load
405
- // + challengeVerifier use, so an adopted manifest updates the cache step-6 reads from.
406
- // All optional — undefined on the M6 backward-compat path → polling is simply off.
407
- pollScheduler: manifestPollScheduler,
408
- manifestProvider,
409
- manifestVersionStore,
410
- rootKeys: manifestRootKeys,
411
- threshold: manifestThreshold,
412
- });
372
+ // H1: a long-running daemon must ride out directory outages — notably the 25-30 min multi-region
373
+ // directory deploy. Use an effectively-unbounded reconnect budget with a capped backoff so each
374
+ // connection keeps retrying and reconnects within ~maxBackoffMs of the directory returning.
375
+ const sharedSignaling = directoryEndpointResolver
376
+ ? undefined
377
+ : new SignalingManager({
378
+ connect: signalingConnect ?? defaultConnect,
379
+ logger,
380
+ maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
381
+ maxBackoffMs: 30_000,
382
+ });
413
383
  const perAgentSignaling = new Map();
414
384
  /**
415
- * Return the directory signaling stream for `agentName`, authenticated as that
416
- * agent. The primary agent reuses the keystone manager + its published node; any
417
- * other agent gets (and caches) a dedicated manager. Falls back to the keystone
418
- * manager when no production bootstrap resolver is configured (in-process tests
419
- * inject a single `signalingConnect` and never exercise the per-agent path).
385
+ * Return the directory signaling stream for `agentName`, authenticated as that agent.
386
+ * Production: a dedicated per-agent manager (created + cached on first use). Test /
387
+ * backward-compat path (no directoryEndpointResolver): the single shared manager.
420
388
  *
421
- * SCOPE (SPINE-5 follow-on): this wires per-agent signaling for REGISTRATION (the
422
- * registration reply frames are routed via the per-agent DaemonRegistrationContext's
423
- * own inbound handler). The daemon's INBOUND SESSION handlers (session_assignment /
424
- * session_request, registerInboundHandler below) are still attached to the keystone
425
- * `signalingManager` only so a NON-primary agent can register but cannot yet RECEIVE
426
- * inbound sessions on its dedicated stream (frames there are unhandled). Not a
427
- * regression (before this, a non-primary agent could not register at all); closing it
428
- * is SPINE-5, which attaches the session inbound handlers per-agent. Tracked in the
429
- * M7 build journal + DoD SPINE-5 scope note.
389
+ * CONN-001 (DOD-CONN-2): the per-agent manager wires BOTH registration AND inbound session
390
+ * handlers (session_assignment / seal_interrupted_request) via wirePerAgentSessionInbound,
391
+ * so a non-primary agent RECEIVES inbound sessions on its own stream closing the prior
392
+ * SPINE-5 gap where only the primary (keystone) received them. Earlier scope note (now
393
+ * resolved): inbound handlers used to be attached to the keystone only; they are now
394
+ * attached per-agent here via wirePerAgentSessionInbound.
430
395
  */
431
396
  function getAgentSignaling(agentName, agentKeyProvider, agentPubkeyHex) {
432
- if (primaryAgent && agentName === primaryAgent.name) {
433
- return { signaling: signalingManager, getNode: getDirectoryNode };
397
+ // CONN-001: test / backward-compat path — a single shared manager (no per-agent isolation;
398
+ // a single injected signalingConnect). In production sharedSignaling is undefined and every
399
+ // agent builds its own dedicated manager below.
400
+ if (sharedSignaling) {
401
+ return { signaling: sharedSignaling, getNode: getDirectoryNode };
434
402
  }
435
403
  const existing = perAgentSignaling.get(agentName);
436
404
  if (existing)
437
405
  return existing;
406
+ // Unreachable in practice: sharedSignaling is defined iff directoryEndpointResolver is absent, and
407
+ // the sharedSignaling branch above already returned. This narrows the resolver for the type-checker
408
+ // and is a defensive guard.
438
409
  if (!directoryEndpointResolver) {
439
- return { signaling: signalingManager, getNode: getDirectoryNode };
410
+ throw new Error("getAgentSignaling: no directory endpoint resolver configured (and no shared manager)");
440
411
  }
412
+ const resolver = directoryEndpointResolver;
441
413
  let nodeRef = null;
442
414
  const connect = createSignalingConnect({
443
- getDirectoryEndpoint: directoryEndpointResolver,
415
+ getDirectoryEndpoint: resolver,
444
416
  getAuthIdentity: () => ({ keyProvider: agentKeyProvider, pubkeyHex: agentPubkeyHex }),
445
417
  logger,
446
418
  challengeVerifier,
@@ -496,6 +468,9 @@ export async function startDaemon(config) {
496
468
  signaling: mgr,
497
469
  logger,
498
470
  });
471
+ // CELLO-M7-CONN-001 (DOD-CONN-2): inbound session_assignment + seal_interrupted_request
472
+ // on THIS agent's own stream, so a non-primary agent receives inbound sessions (SPINE-5).
473
+ wirePerAgentSessionInbound(mgr);
499
474
  return entry;
500
475
  }
501
476
  /** Resolve once `mgr` reaches "connected", or false on timeout. */
@@ -510,9 +485,9 @@ export async function startDaemon(config) {
510
485
  * Stop and forget a dedicated per-agent signaling manager. Called when an agent's
511
486
  * registration fails terminally — otherwise the lazily-created manager (and its
512
487
  * libp2p node + effectively-unbounded reconnect loop) would keep reconnecting forever
513
- * for an agent that is not registered/online. No-op for the primary (it reuses the
514
- * keystone manager, which is never stored here) and for agents with no dedicated
515
- * manager. On a later retry, getAgentSignaling re-creates it.
488
+ * for an agent that is not registered/online. No-op on the test path (the shared manager is not
489
+ * stored in perAgentSignaling) and for agents with no dedicated manager. On a later retry,
490
+ * getAgentSignaling re-creates it.
516
491
  */
517
492
  async function dropAgentSignaling(agentName) {
518
493
  const entry = perAgentSignaling.get(agentName);
@@ -522,63 +497,113 @@ export async function startDaemon(config) {
522
497
  await entry.signaling.stop();
523
498
  logger.info("agent.signaling.dropped", { agentName });
524
499
  }
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
- // Returns a disposer that UNREGISTERS the six keystone listeners. Each wire*/register* helper returns
531
- // its own unregister fn; the keystone manager is shared and never stopped, so (unlike the per-agent
532
- // managers, which unregister implicitly on stop) these MUST be unregistered explicitly when the
533
- // primary is cleared or re-elected — otherwise removing-then-recreating the primary STACKS a second
534
- // full set of listeners on the shared stream, double-firing every ceremony/seal frame (review HIGH).
535
- function wireKeystonePrimary(agent) {
536
- const unregister = [];
537
- unregister.push(wireSessionCeremonyHandler({
500
+ // CONN-001: wire a manager's session/seal/offer handlers for `agent`. Used ONLY for the
501
+ // in-process test path's shared manager production wires these PER-AGENT in getAgentSignaling.
502
+ function wireSharedHandlers(agent, mgr) {
503
+ wireSessionCeremonyHandler({
538
504
  agentName: agent.name,
539
505
  persistence: getPersistence(agent.name),
540
506
  agentPubkeyHex: agent.pubkey,
541
507
  getNode: getDirectoryNode,
542
508
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
543
- signaling: signalingManager,
509
+ signaling: mgr,
544
510
  logger,
545
- }));
546
- unregister.push(wireSealCeremonyHandler({
511
+ });
512
+ wireSealCeremonyHandler({
547
513
  agentName: agent.name,
548
514
  persistence: getPersistence(agent.name),
549
515
  agentPubkeyHex: agent.pubkey,
550
516
  getNode: getDirectoryNode,
551
517
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
552
- signaling: signalingManager,
518
+ signaling: mgr,
553
519
  logger,
554
- }));
555
- unregister.push(wireSessionOfferHandler({
520
+ });
521
+ wireSessionOfferHandler({
556
522
  agentName: agent.name,
557
523
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
558
- signaling: signalingManager,
524
+ signaling: mgr,
559
525
  logger,
560
- }));
561
- // The primary closes its OWN sessions over the keystone stream, so the directory routes its
562
- // session_sealed / seal_unilateral_confirmed / seal_unilateral_notification frames there. These
563
- // seal-completion listeners MUST be registered for the keystone primary (startup OR runtime
564
- // election) — otherwise the close waiter never resolves and the seal silently never finalizes
565
- // (review HIGH: previously these were a separate startup-only block, skipped on a fresh install).
566
- // The register* functions are hoisted declarations within startDaemon, so callable here.
567
- unregister.push(registerSessionSealedListener(signalingManager, agent.name, agent.pubkey));
568
- unregister.push(registerUnilateralConfirmedListener(signalingManager, agent.name, agent.pubkey));
569
- unregister.push(registerUnilateralUpgradeListener(signalingManager, agent.name, agent.pubkey));
570
- return () => { for (const u of unregister) {
526
+ });
527
+ registerSessionSealedListener(mgr, agent.name, agent.pubkey);
528
+ registerUnilateralConfirmedListener(mgr, agent.name, agent.pubkey);
529
+ registerUnilateralUpgradeListener(mgr, agent.name, agent.pubkey);
530
+ }
531
+ // CONN-001: the directory signaling manager that OWNS operations for `agentName`. In production
532
+ // that is the agent's OWN per-agent manager (authenticated as itself); on the in-process test
533
+ // path it is the single shared manager (agentName ignored). Returns undefined only when the agent
534
+ // is not loaded in production (e.g. removed mid-flow) — callers treat that as a send failure.
535
+ function signalingFor(agentName) {
536
+ if (sharedSignaling)
537
+ return sharedSignaling;
538
+ const kp = keyProviders.get(agentName);
539
+ const agent = loadedAgents.find((a) => a.name === agentName);
540
+ if (!kp || !agent)
541
+ return undefined;
542
+ return getAgentSignaling(agentName, kp, agent.pubkey).signaling;
543
+ }
544
+ // CONN-001: send a frame over the OWNING agent's directory manager (per-agent in production, the
545
+ // shared manager in tests). If the agent has no manager (e.g. removed mid-flow), return a send
546
+ // failure rather than throw — callers already branch on `!result.ok`.
547
+ async function sendOver(agentName, frame) {
548
+ const mgr = signalingFor(agentName);
549
+ if (!mgr)
550
+ return { ok: false, reason: "directory_unreachable" };
551
+ return mgr.sendRaw(frame);
552
+ }
553
+ // CONN-001: aggregate directory signaling status for `cello status`. Test path → the shared
554
+ // manager's status. Production → connected if ANY online agent's connection is connected; else
555
+ // reconnecting if any per-agent connection exists; else disconnected (no agents online → no
556
+ // connections, which is correct — there is no shared connection to be "reconnecting").
557
+ function directorySignalingStatus() {
558
+ if (sharedSignaling)
559
+ return sharedSignaling.status;
560
+ const managers = [...perAgentSignaling.values()];
561
+ // CONN-001 (fallback-finder MED): a single daemon-level field cannot fully represent N independent
562
+ // per-agent connections, but it must NOT show "connected" while any agent is severed (that would
563
+ // mask a partial directory outage — the severed agent silently never receives inbound). So report
564
+ // "connected" ONLY when every per-agent manager is connected; otherwise "reconnecting" (degraded or
565
+ // none). No managers (no agent online) → "reconnecting", matching pre-CONN-001 fresh-install
566
+ // behavior. (Per-agent connection state is the future faithful surface.)
567
+ if (managers.length === 0)
568
+ return "reconnecting";
569
+ return managers.every((m) => m.signaling.status === "connected") ? "connected" : "reconnecting";
570
+ }
571
+ // CONN-001: stop every directory signaling connection on shutdown — the shared test manager AND
572
+ // every per-agent manager — so no reconnect loop is orphaned past shutdown.
573
+ async function stopAllSignaling() {
574
+ if (sharedSignaling) {
575
+ try {
576
+ await sharedSignaling.stop();
577
+ }
578
+ catch { /* best-effort */ }
579
+ }
580
+ for (const entry of perAgentSignaling.values()) {
571
581
  try {
572
- u();
582
+ await entry.signaling.stop();
573
583
  }
574
- catch { /* unregister is best-effort */ }
575
- } };
584
+ catch { /* best-effort */ }
585
+ }
586
+ }
587
+ // CONN-001: test path only — wire the shared manager's session/seal handlers for the first loaded
588
+ // agent. Production wires them per-agent (getAgentSignaling). There is NO keystone identity to
589
+ // elect and no re-election machinery: the shared test manager is never removed, and in production
590
+ // a fresh create-agent brings up that agent's OWN connection (no shared door to elect into).
591
+ if (sharedSignaling && loadedAgents.length > 0) {
592
+ const first = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
593
+ wireSharedHandlers(first, sharedSignaling);
594
+ }
595
+ // CELLO-M7-CONN-001 (DOD-CONN-1, code-review HIGH): in PRODUCTION, bring up EACH loaded agent's OWN
596
+ // directory connection at startup. A registered agent must have a directory presence whenever the
597
+ // daemon runs — so the directory can route inbound session_assignment/seal to it (notably after a
598
+ // restart, where login does NOT auto-start agents), and `cello status` reflects directory_signaling
599
+ // as soon as the daemon is up. This replaces the pre-CONN-001 keystone (which connected only the
600
+ // loaded PRIMARY at startup) with a per-agent connection for EVERY loaded agent. Idempotent with the
601
+ // create/register/start connect (getAgentSignaling caches per agent).
602
+ if (!sharedSignaling) {
603
+ for (const agent of loadedAgents) {
604
+ getAgentSignaling(agent.name, agent.keyProvider, agent.pubkey);
605
+ }
576
606
  }
577
- // Disposer for the currently-wired keystone primary listeners (CELLO-M7-REMOVE-001): call it before
578
- // clearing or re-electing the primary so the shared stream never carries a stale/duplicate set.
579
- let keystoneDispose;
580
- if (primaryAgent)
581
- keystoneDispose = wireKeystonePrimary(primaryAgent);
582
607
  // Per-connection state: tracks which agent is "current" for each IPC connection.
583
608
  // Key = connectionId (assigned by IPC server), Value = current agent name or null.
584
609
  const perConnectionState = new Map();
@@ -893,7 +918,7 @@ export async function startDaemon(config) {
893
918
  const interrupted_sessions = buildInterruptedSessions();
894
919
  return {
895
920
  daemon: "running",
896
- directory_signaling: signalingManager.status,
921
+ directory_signaling: directorySignalingStatus(),
897
922
  agents,
898
923
  connections,
899
924
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
@@ -932,6 +957,18 @@ export async function startDaemon(config) {
932
957
  return { ok: true };
933
958
  }
934
959
  onlineAgents.add(name);
960
+ // CELLO-M7-CONN-001 (DOD-CONN-2, code-review HIGH): the "online" transition establishes THIS
961
+ // agent's OWN directory signaling connection (the documented getAgentSignaling "online" trigger),
962
+ // so the directory has a stream to push inbound session_assignment / seal_interrupted_request to.
963
+ // Without this, a started receiver agent sitting in cello_await_session (notably after a daemon
964
+ // restart, where login does NOT auto-start agents) would have no stream and never receive inbound —
965
+ // a regression of the pre-CONN-001 keystone, which connected the primary at startup. Lazy +
966
+ // idempotent (getAgentSignaling reuses an existing manager); the test path returns the shared one.
967
+ const startKp = keyProviders.get(name);
968
+ if (startKp && agent.pubkey) {
969
+ getAgentSignaling(name, startKp, agent.pubkey);
970
+ logger.info("agent.directory.connection.initiated", { agentName: name, agentPubkey: agent.pubkey });
971
+ }
935
972
  // DOD-LOOP-1: each online agent gets its OWN standing receiver, so two agents on one daemon
936
973
  // (loopback) never contend for a single one. Fire-and-forget (initiate/accept also ensure on
937
974
  // demand); never let it throw out of the handler. Once the SR is up, re-park any of THIS
@@ -983,23 +1020,16 @@ export async function startDaemon(config) {
983
1020
  const loaded = { name, pubkey: pubkeyHex, keyProvider };
984
1021
  loadedAgents.push(loaded);
985
1022
  agents.push({ name, state: "registered", pubkey: pubkeyHex });
986
- // CELLO-M7-ONBOARD-001: on a FRESH install the daemon started with no agents, so there was no
987
- // keystone identity and the directory door is stuck `reconnecting`. Elect this first agent as the
988
- // keystone primary + wire its ceremony/seal handlers; the running reconnect loop then
989
- // authenticates with it (getAuthIdentity now returns it) and connects on its next attempt — no
990
- // restart needed. Only the FIRST agent is elected (loadedAgents is empty here, so it is also the
991
- // name-sort min). NOTE (accepted LOW, review): if the operator then creates a lexicographically
992
- // SMALLER second agent before any restart, the keystone primary stays this one until the next
993
- // restart, where it re-derives to sort[0] benign (each agent authenticates its own stream; the
994
- // demoted agent gets fully wired via the per-agent path on restart), and not worth runtime
995
- // re-election (un-wire/re-wire) churn.
996
- if (!primaryAgent) {
997
- // Dispose any prior keystone wiring before re-electing, so listeners never stack (REMOVE-001).
998
- keystoneDispose?.();
999
- primaryAgent = loaded;
1000
- keystoneDispose = wireKeystonePrimary(loaded);
1001
- logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
1002
- }
1023
+ // CELLO-M7-CONN-001 (DOD-CONN-1, supersedes ONBOARD-001 keystone election): on a fresh install
1024
+ // the daemon started with zero agents, so there were no directory connections. Bring up THIS
1025
+ // agent's OWN directory connection now (production: getAgentSignaling creates + connects a
1026
+ // dedicated manager authenticated as this agent; the directory door becomes active with no
1027
+ // restart). There is no shared keystone to elect into. In the test path getAgentSignaling returns
1028
+ // the already-present shared manager (no-op).
1029
+ getAgentSignaling(name, keyProvider, pubkeyHex);
1030
+ // "initiated" not "established": getAgentSignaling starts the connection but does not await it
1031
+ // (the SignalingManager emits directory.signaling.connected when it actually authenticates).
1032
+ logger.info("agent.directory.connection.initiated", { agentName: name, agentPubkey: pubkeyHex });
1003
1033
  }
1004
1034
  catch (err) {
1005
1035
  logger.error("persist.identity.persist.failed", { agentName: name, error: err instanceof Error ? err.message : String(err) });
@@ -1030,16 +1060,18 @@ export async function startDaemon(config) {
1030
1060
  const sigHex = Buffer.from(await signer.sign(tbs)).toString("hex");
1031
1061
  // If the agent has no live signaling (e.g. a re-push of an already-retired agent), getAgentSignaling
1032
1062
  // lazily creates a dedicated manager — drop it again afterwards so we don't leak a reconnect loop.
1033
- const hadSignaling = perAgentSignaling.has(agentName) || primaryAgent?.name === agentName;
1063
+ const hadSignaling = perAgentSignaling.has(agentName);
1034
1064
  const { signaling } = getAgentSignaling(agentName, signer, kLocalPubkeyHex);
1035
- const createdSignaling = !hadSignaling && primaryAgent?.name !== agentName;
1065
+ // createdSignaling is true only when this call lazily created a NEW per-agent manager (production);
1066
+ // on the shared test path perAgentSignaling stays empty, so it is false and dropAgentSignaling no-ops.
1067
+ const createdSignaling = !hadSignaling && perAgentSignaling.has(agentName);
1036
1068
  try {
1037
1069
  const connected = await waitForSignalingConnected(signaling, 10_000);
1038
1070
  if (!connected)
1039
1071
  return { recorded: false, reason: "directory_unreachable" };
1040
1072
  let resolveFrame;
1041
1073
  const pending = new Promise((r) => { resolveFrame = r; });
1042
- // Match the reply by agent_id so a revocation on the shared keystone is never cross-wired.
1074
+ // Match the reply by agent_id so a revocation on a shared signaling stream is never cross-wired.
1043
1075
  const unregister = signaling.registerInboundHandler((frame) => {
1044
1076
  const t = frame["type"];
1045
1077
  if ((t === "agent_revocation_ack" || t === "agent_revocation_error") && frame["agent_id"] === regAgentId)
@@ -1143,14 +1175,11 @@ export async function startDaemon(config) {
1143
1175
  const ai = agents.findIndex((a) => a.name === name);
1144
1176
  if (ai >= 0)
1145
1177
  agents.splice(ai, 1);
1146
- // If the retired agent was the directory keystone, UNWIRE its keystone listeners + clear it
1147
- // (review HIGH-2: the listeners were never unregistered double-wire on re-election).
1148
- if (primaryAgent && primaryAgent.name === name) {
1149
- keystoneDispose?.();
1150
- keystoneDispose = undefined;
1151
- primaryAgent = undefined;
1152
- logger.info("directory.keystone.cleared", { agentName: name, agentId });
1153
- }
1178
+ // CELLO-M7-CONN-001 (DOD-CONN-1): no keystone to clear the agent's OWN per-agent directory
1179
+ // connection was already torn down above (dropAgentSignaling). Removing any agent disturbs only
1180
+ // its own connection; no other agent's connection, and no shared "keystone", is affected. This is
1181
+ // the fix for the Demo1 bug (the keystone lingered authenticated as the removed agent).
1182
+ logger.info("agent.directory.connection.dropped", { agentName: name, agentId, reason: "agent_removed" });
1154
1183
  // Drop the retired agent as any connection's current agent.
1155
1184
  for (const [connId, state] of perConnectionState) {
1156
1185
  if (state.currentAgent === name) {
@@ -1304,8 +1333,8 @@ export async function startDaemon(config) {
1304
1333
  const directoryEndpoint = { peer_id: ep.peerId, multiaddrs: [ep.multiaddr] };
1305
1334
  // Multi-agent: register over THIS agent's own directory signaling stream (authed
1306
1335
  // as this agent), so the directory routes its dkg_complete/register_success back
1307
- // to it. The primary agent reuses the keystone stream; any other agent gets a
1308
- // dedicated one. The DKG's FROST streams open on this agent's directory node.
1336
+ // to it. CONN-001: every agent has its own dedicated stream (no shared keystone). The
1337
+ // DKG's FROST streams open on this agent's directory node.
1309
1338
  const agentRecord = loadedAgents.find((a) => a.name === name);
1310
1339
  const agentPubkeyHex = agentRecord?.pubkey ?? Buffer.from(await keyProvider.getPublicKey()).toString("hex");
1311
1340
  const { signaling: agentSignaling, getNode: agentGetNode } = getAgentSignaling(name, keyProvider, agentPubkeyHex);
@@ -1388,9 +1417,9 @@ export async function startDaemon(config) {
1388
1417
  // Prevents duplicate concurrent seal-interrupted attempts for the same (agent, session) (AC-011).
1389
1418
  const sealInterruptedInProgress = new Set();
1390
1419
  const pendingSealWaiters = new Map();
1391
- // M7 DOD-SPINE-7: register the session_sealed completion handler on a signaling seam (keystone
1392
- // for the primary agent, per-agent for the rest — the directory routes session_sealed to the
1393
- // session-owning agent's authenticated stream). Function declaration so getAgentSignaling
1420
+ // M7 DOD-SPINE-7: register the session_sealed completion handler on a signaling manager — per-agent
1421
+ // in production (the directory routes session_sealed to the session-owning agent's authenticated
1422
+ // stream), or the shared manager on the test path. Function declaration so getAgentSignaling
1394
1423
  // (defined earlier, called at runtime) can wire it per-agent.
1395
1424
  function registerSessionSealedListener(signaling, agentName, agentPubkeyHex) {
1396
1425
  return signaling.registerInboundHandler((frame) => {
@@ -1673,7 +1702,7 @@ export async function startDaemon(config) {
1673
1702
  handlers.set("cello_status", async (_params, connectionId) => {
1674
1703
  return {
1675
1704
  daemon: "running",
1676
- directory_signaling: signalingManager.status,
1705
+ directory_signaling: directorySignalingStatus(),
1677
1706
  agents: getAgentsForConnection(connectionId),
1678
1707
  connections,
1679
1708
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
@@ -1875,7 +1904,7 @@ export async function startDaemon(config) {
1875
1904
  };
1876
1905
  }
1877
1906
  // DB-001: signaling stream reconnecting
1878
- if (record.status === "interrupted" && signalingManager.status === "reconnecting") {
1907
+ if (record.status === "interrupted" && signalingFor(record.agent_name)?.status === "reconnecting") {
1879
1908
  return {
1880
1909
  ok: false,
1881
1910
  reason: "signaling_reconnecting",
@@ -1975,7 +2004,7 @@ export async function startDaemon(config) {
1975
2004
  let resolveUni;
1976
2005
  const uniP = new Promise((r) => { resolveUni = r; });
1977
2006
  pendingUnilateralWaiters.set(sessionId, resolveUni);
1978
- const sent = await signalingManager.sendRaw({
2007
+ const sent = await sendOver(record.agent_name, {
1979
2008
  type: "seal_unilateral",
1980
2009
  session_id: new Uint8Array(Buffer.from(sessionId, "hex")),
1981
2010
  reported_root: new Uint8Array(Buffer.from(submit.reportedRootHex, "hex")),
@@ -2404,13 +2433,24 @@ export async function startDaemon(config) {
2404
2433
  return;
2405
2434
  }
2406
2435
  const reject = async (reason) => {
2407
- await signalingManager.sendRaw({
2436
+ // CONN-001: send the rejection over the LOCAL responder agent's own stream (the agent whose
2437
+ // pubkey is counterpartyPubkey — the stream this request arrived on). If unresolved, sendOver
2438
+ // reports a send failure rather than throwing.
2439
+ const sent = await sendOver(agents.find((a) => a.pubkey === counterpartyPubkey)?.name ?? "", {
2408
2440
  type: "seal_interrupted_rejection",
2409
2441
  sessionId,
2410
2442
  initiatorPubkey,
2411
2443
  reason,
2412
2444
  });
2413
- logger.warn("session.interrupted.request.rejected", { sessionId, reason, correlationId });
2445
+ // fallback-finder LOW: don't log the rejection as delivered when the send failed (e.g. no local
2446
+ // agent for counterpartyPubkey, or a transient send error) — the counterparty would otherwise
2447
+ // appear rejected but only ever see a timeout.
2448
+ if (sent.ok) {
2449
+ logger.warn("session.interrupted.request.rejected", { sessionId, reason, correlationId });
2450
+ }
2451
+ else {
2452
+ logger.warn("session.interrupted.request.rejection.send.failed", { sessionId, reason, sendReason: sent.reason, correlationId });
2453
+ }
2414
2454
  };
2415
2455
  // DOD-LOOP-1: resolve the addressed local agent FIRST — the composite (agent, session_id) key
2416
2456
  // needs it. The request must be addressed to one of our agents (counterpartyPubkey is OUR
@@ -2489,7 +2529,7 @@ export async function startDaemon(config) {
2489
2529
  nonce,
2490
2530
  sealInterruptedLeaf: ownLeaf,
2491
2531
  };
2492
- const sendResult = await signalingManager.sendRaw(ack);
2532
+ const sendResult = await sendOver(localAgent.name, ack);
2493
2533
  if (!sendResult.ok) {
2494
2534
  logger.error("session.interrupted.ack.send.failed", {
2495
2535
  sessionId,
@@ -2506,13 +2546,6 @@ export async function startDaemon(config) {
2506
2546
  correlationId,
2507
2547
  });
2508
2548
  }
2509
- // Register the persistent responder. This is a REAL registered handler (not a
2510
- // test-only path): it fires for every inbound seal_interrupted_request.
2511
- signalingManager.registerInboundHandler((frame) => {
2512
- if (frame["type"] !== "seal_interrupted_request")
2513
- return;
2514
- void handleInboundSealInterruptedRequest(frame);
2515
- });
2516
2549
  // Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
2517
2550
  // the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
2518
2551
  // local agent (participant_b), so both are keyed by agent name.
@@ -2812,17 +2845,34 @@ export async function startDaemon(config) {
2812
2845
  });
2813
2846
  });
2814
2847
  }
2815
- signalingManager.registerInboundHandler((frame) => {
2816
- if (frame["type"] !== "session_assignment")
2817
- return;
2818
- handleInboundSessionAssignment(frame);
2819
- });
2820
- // M7 DOD-SPINE-7: session_sealed listener. The directory delivers this over the SESSION-OWNING
2821
- // CELLO-M7-ONBOARD-001: the keystone primary's seal-completion listeners (session_sealed /
2822
- // seal_unilateral_confirmed / seal_unilateral_notification) are now registered inside
2823
- // wireKeystonePrimary — at startup for a loaded primary AND at runtime when the first agent is
2824
- // elected (cello_create_agent on a fresh install). They must NOT be a separate startup-only block,
2825
- // or a fresh-install primary's seals would silently never finalize (review HIGH).
2848
+ // CELLO-M7-CONN-001 (DOD-CONN-2): wire the inbound session/seal responders onto a GIVEN
2849
+ // signaling manager. Called for EVERY agent's own per-agent manager (via getAgentSignaling)
2850
+ // so each agent receives inbound session_assignment + seal_interrupted_request on its OWN
2851
+ // authenticated stream — closing the SPINE-5 gap where only the primary (whose stream WAS
2852
+ // the keystone) received them. A frame arrives on exactly one agent's stream, so there is no
2853
+ // double-dispatch; each handler resolves the local agent internally and that resolution
2854
+ // matches the stream the directory routed the frame to.
2855
+ function wirePerAgentSessionInbound(mgr) {
2856
+ mgr.registerInboundHandler((frame) => {
2857
+ if (frame["type"] !== "seal_interrupted_request")
2858
+ return;
2859
+ void handleInboundSealInterruptedRequest(frame);
2860
+ });
2861
+ mgr.registerInboundHandler((frame) => {
2862
+ if (frame["type"] !== "session_assignment")
2863
+ return;
2864
+ handleInboundSessionAssignment(frame);
2865
+ });
2866
+ }
2867
+ // CONN-001: test path only — the shared manager's inbound session/seal responders. Production
2868
+ // wires them per-agent in getAgentSignaling (each agent receives on its own stream).
2869
+ if (sharedSignaling)
2870
+ wirePerAgentSessionInbound(sharedSignaling);
2871
+ // M7 DOD-SPINE-7 / CELLO-M7-CONN-001: the seal-completion listeners (session_sealed /
2872
+ // seal_unilateral_confirmed / seal_unilateral_notification) are registered PER-AGENT inside
2873
+ // getAgentSignaling (the directory routes each over the session-owning agent's authenticated
2874
+ // stream), and on the shared manager for the test path via wireSharedHandlers. No keystone, no
2875
+ // runtime election — a fresh-install agent gets them when it comes online (create/start).
2826
2876
  // cello_await_session — the counterparty's blocking pull for the next inbound session.
2827
2877
  // Returns immediately if one is already queued for the current agent (FIFO), otherwise
2828
2878
  // blocks until one arrives or timeout_ms elapses. Response shape matches the established
@@ -2876,13 +2926,18 @@ export async function startDaemon(config) {
2876
2926
  // out). Extracted so the two flows wait identically — the directory pass-through
2877
2927
  // routing (directory-node.ts) is the only wired transport for this exchange.
2878
2928
  const SEAL_INTERRUPTED_TIMEOUT_MS = 30_000;
2879
- function awaitSealAck(sessionId) {
2929
+ function awaitSealAck(sessionId, mgr) {
2880
2930
  return new Promise((resolve) => {
2931
+ // CONN-001: await the ack on the OWNING agent's own stream (per-agent in prod, shared in test).
2932
+ if (!mgr) {
2933
+ resolve({ type: "timeout" });
2934
+ return;
2935
+ }
2881
2936
  const timeoutHandle = setTimeout(() => {
2882
2937
  unregister();
2883
2938
  resolve({ type: "timeout" });
2884
2939
  }, SEAL_INTERRUPTED_TIMEOUT_MS);
2885
- const unregister = signalingManager.registerInboundHandler((frame) => {
2940
+ const unregister = mgr.registerInboundHandler((frame) => {
2886
2941
  if (frame.type !== "seal_interrupted_ack" && frame.type !== "seal_interrupted_rejection") {
2887
2942
  return;
2888
2943
  }
@@ -2928,7 +2983,7 @@ export async function startDaemon(config) {
2928
2983
  ? sessionNodeManager.getSessionTreeRootHex(record.agent_name, sessionId)
2929
2984
  : merkleRootAtInterruption;
2930
2985
  // DB-001: check signaling status before attempting to send
2931
- if (signalingManager.status === "reconnecting") {
2986
+ if (signalingFor(record.agent_name)?.status === "reconnecting") {
2932
2987
  logger.error("session.interrupted.seal.failed", {
2933
2988
  sessionId,
2934
2989
  agentName: record.agent_name,
@@ -2974,7 +3029,7 @@ export async function startDaemon(config) {
2974
3029
  merkleRootAtInterruption: effectiveRoot,
2975
3030
  nonce,
2976
3031
  };
2977
- const sendResult = await signalingManager.sendRaw(request);
3032
+ const sendResult = await sendOver(record.agent_name, request);
2978
3033
  if (!sendResult.ok) {
2979
3034
  logger.error("session.interrupted.seal.failed", {
2980
3035
  sessionId,
@@ -2990,7 +3045,7 @@ export async function startDaemon(config) {
2990
3045
  };
2991
3046
  }
2992
3047
  // Wait for counterparty ack/rejection via the shared signaling await machinery.
2993
- const ackResult = await awaitSealAck(sessionId);
3048
+ const ackResult = await awaitSealAck(sessionId, signalingFor(record.agent_name));
2994
3049
  if (ackResult.type === "timeout") {
2995
3050
  logger.error("session.interrupted.seal.failed", {
2996
3051
  sessionId,
@@ -3098,7 +3153,7 @@ export async function startDaemon(config) {
3098
3153
  const myPubkeyHex = agent?.pubkey ?? "";
3099
3154
  const kp = keyProviders.get(record.agent_name);
3100
3155
  // DB-002: never initiate a partial seal while signaling is reconnecting.
3101
- if (signalingManager.status === "reconnecting") {
3156
+ if (signalingFor(record.agent_name)?.status === "reconnecting") {
3102
3157
  return {
3103
3158
  ok: false,
3104
3159
  reason: "signaling_reconnecting",
@@ -3160,7 +3215,7 @@ export async function startDaemon(config) {
3160
3215
  // Submit the SEAL request over the directory signaling stream (the SAME wired
3161
3216
  // pass-through the interrupted-seal flow uses) and AWAIT the counterparty's
3162
3217
  // bilateral ack. We never report success on a fire-and-forget send.
3163
- const sendResult = await signalingManager.sendRaw({
3218
+ const sendResult = await sendOver(record.agent_name, {
3164
3219
  type: "seal_interrupted_request",
3165
3220
  sessionId,
3166
3221
  initiatorPubkey: myPubkeyHex,
@@ -3178,14 +3233,14 @@ export async function startDaemon(config) {
3178
3233
  });
3179
3234
  return {
3180
3235
  ok: false,
3181
- reason: sendResult.reason,
3236
+ reason: sendResult.reason ?? "directory_unreachable",
3182
3237
  guidance: "guidance" in sendResult && typeof sendResult.guidance === "string"
3183
3238
  ? sendResult.guidance
3184
3239
  : "The seal could not be submitted to the directory. Retry once cello status shows directory_signaling connected.",
3185
3240
  };
3186
3241
  }
3187
3242
  // Wait for the counterparty's bilateral ack (or rejection / timeout).
3188
- const ackResult = await awaitSealAck(sessionId);
3243
+ const ackResult = await awaitSealAck(sessionId, signalingFor(record.agent_name));
3189
3244
  if (ackResult.type === "timeout" || ackResult.type === "seal_interrupted_rejection") {
3190
3245
  const reason = ackResult.type === "timeout" ? "seal_counterparty_unavailable" : "seal_rejected_by_counterparty";
3191
3246
  logger.error("session.seal.initiate.failed", {
@@ -3446,25 +3501,23 @@ export async function startDaemon(config) {
3446
3501
  agentCount: loadedAgents.length,
3447
3502
  manifestVerified,
3448
3503
  });
3449
- // M7-MANIFEST-002 / DOD-AUTH-2: background manifest polling is now ACTIVE. The keystone
3450
- // SignalingManager (constructed above with the poll deps) calls startPolling() when its
3451
- // stream reaches connected — it re-polls the directory on the randomized 6–12h interval
3452
- // and adopts a newer signed manifest (handleManifestPollResponse). No separate wiring
3453
- // needed here; poll lifecycle = the keystone connection lifecycle.
3504
+ // CELLO-M7-CONN-001 (DOD-CONN-3): background manifest polling runs daemon-level over
3505
+ // unauthenticated HTTP (startHttpManifestPoll above), NOT over a signaling stream. Its
3506
+ // lifecycle is the daemon's, not any agent connection's so it polls even with zero agents.
3454
3507
  // Graceful shutdown
3455
3508
  async function stop(reason) {
3456
- // Cancel any pending manifest poll timer
3509
+ // CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
3510
+ // tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
3511
+ // no-poll case (scheduler present but poll not started).
3512
+ stopHttpManifestPoll?.();
3457
3513
  if (manifestPollScheduler) {
3458
3514
  manifestPollScheduler.cancel();
3459
3515
  }
3460
3516
  logger.info("daemon.stopped", { pid: process.pid, reason });
3461
- // Stop SignalingManager (flushes pending ops with shutdown error, cancels reconnect loop)
3462
- await signalingManager.stop();
3463
- // Stop every per-agent signaling stream too (multi-agent), so no agent's directory
3464
- // node / reconnect loop is orphaned past shutdown.
3465
- for (const entry of perAgentSignaling.values()) {
3466
- await entry.signaling.stop();
3467
- }
3517
+ // CONN-001 (code-review LOW): stopAllSignaling() stops the shared manager AND every per-agent
3518
+ // manager (best-effort). The previously-separate per-agent stop loop here was redundant and
3519
+ // unguarded (would abort the rest of shutdown if stop() ever threw on a second call) — removed.
3520
+ await stopAllSignaling();
3468
3521
  // Gracefully mark active sessions interrupted (AC-009) before stopping IPC
3469
3522
  await sessionNodeManager.gracefulShutdown();
3470
3523
  await ipcServer.stop();