@cello-protocol/daemon 0.0.54 → 0.0.56

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.
@@ -35,7 +35,8 @@ import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/t
35
35
  import { verify } from "@cello-protocol/crypto";
36
36
  import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
37
37
  import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
38
- import { AgentRelayClient, LEAF_KIND_CTRL } from "./session-relay-client.js";
38
+ import { isValidMultiaddr } from "@cello-protocol/transport";
39
+ import { AgentRelayClient, LEAF_KIND_CTRL, extractErrorMessage } from "./session-relay-client.js";
39
40
  import { RelayReceiptStore } from "./relay-receipt-store.js";
40
41
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
41
42
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
@@ -87,6 +88,8 @@ export class SessionNodeManager {
87
88
  #agentsWantingReceiver = new Set();
88
89
  // M8B F14: standing-receiver create retry schedule (see constructor opts).
89
90
  #srRetryDelaysMs;
91
+ /** DOD-NAT-REACHABILITY-1: reservation deadline — see #startReceiverNode. */
92
+ #srReservationTimeoutMs;
90
93
  // Agents whose removeStandingReceiverForAgent ran while an #ensureStandingReceiver for them was
91
94
  // in flight (parked on createNode/start, so the map had no entry to delete yet). The in-flight
92
95
  // ensure checks this after start() and tears the fresh node down instead of installing an SR for
@@ -200,6 +203,7 @@ export class SessionNodeManager {
200
203
  }
201
204
  this.#autoNatProbers = opts.autoNatProbers ?? (() => []);
202
205
  this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
206
+ this.#srReservationTimeoutMs = opts.standingReceiverReservationTimeoutMs ?? 8_000;
203
207
  this.#securityGateway = opts.securityGateway ?? new PassthroughGatewayClient();
204
208
  }
205
209
  /**
@@ -2455,6 +2459,15 @@ export class SessionNodeManager {
2455
2459
  if (addrs.length === 0) {
2456
2460
  return { ok: false, reason: "no_counterparty_addrs", error: "the assignment carried no counterparty session addrs to dial" };
2457
2461
  }
2462
+ // DOD-NAT-REACHABILITY-1: a /p2p-circuit counterparty address is dialed
2463
+ // THROUGH its relay, so the gater must admit that relay peer OUTBOUND. The
2464
+ // relay id is embedded in the address, which arrived inside the FROST-signed
2465
+ // assignment — the same authorization rail as the assigned witness relay.
2466
+ for (const addr of addrs) {
2467
+ const viaRelay = addr.match(/\/p2p\/([^/]+)\/p2p-circuit/);
2468
+ if (viaRelay)
2469
+ entry.gater.setAllowedOutboundPeer(viaRelay[1]);
2470
+ }
2458
2471
  let lastError = "";
2459
2472
  for (const addr of addrs) {
2460
2473
  try {
@@ -2467,8 +2480,11 @@ export class SessionNodeManager {
2467
2480
  return { ok: true };
2468
2481
  }
2469
2482
  catch (err) {
2470
- // error.message extracted never [object Object]; try the next addr.
2471
- lastError = err instanceof Error ? err.message : String(err);
2483
+ // extractErrorMessage handles the transport's structured plain-object
2484
+ // throws (dial() never throws Error instances) the old
2485
+ // `instanceof Error` idiom logged "[object Object]" on every dial
2486
+ // failure; try the next addr.
2487
+ lastError = extractErrorMessage(err);
2472
2488
  }
2473
2489
  }
2474
2490
  this.#logger.warn("session.transport.connect.failed", {
@@ -3871,6 +3887,197 @@ export class SessionNodeManager {
3871
3887
  this.#standingReceiverCreating.delete(agentName);
3872
3888
  }
3873
3889
  }
3890
+ /**
3891
+ * DOD-NAT-REACHABILITY-1 (Phase 2): relay endpoints the DIRECTORY handed this
3892
+ * agent at signaling-auth time — the freshest, health-filtered view of the
3893
+ * relay pool, and the only source a FRESH agent (no session history) has.
3894
+ */
3895
+ #directoryRelayEndpoints = new Map();
3896
+ /**
3897
+ * DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
3898
+ * for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
3899
+ * reconnect). If the agent's standing receiver is up but holds NO reservation —
3900
+ * the agent-online ensure raced ahead of auth_ok, or every relay was down at
3901
+ * create time — rebuild it now so the agent becomes dialable without waiting
3902
+ * for a session handoff that (being unreachable) would never come.
3903
+ */
3904
+ setDirectoryRelayEndpoints(agentName, endpoints) {
3905
+ this.#directoryRelayEndpoints.set(agentName, endpoints);
3906
+ if (endpoints.length === 0 || this.#shuttingDown)
3907
+ return;
3908
+ const sr = this.#standingReceivers.get(agentName);
3909
+ if (!sr)
3910
+ return; // not ensured yet — the coming ensure reads the map
3911
+ if (sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit")))
3912
+ return; // already reserved
3913
+ this.#logger.info("session.standing_receiver.reservation.rebuild", {
3914
+ agentName,
3915
+ relayPeerIds: endpoints.map((e) => e.relayPeerId),
3916
+ });
3917
+ void this.#rebuildStandingReceiver(agentName);
3918
+ }
3919
+ /**
3920
+ * Replace an agent's reservation-less standing receiver with one that reserves.
3921
+ *
3922
+ * Deliberately NOT removeStandingReceiverForAgent()+ensureStandingReceiverForAgent():
3923
+ * the public remove CLEARS #agentsWantingReceiver, so a cello_stop_agent landing in
3924
+ * the window while node.stop() is awaited would find no map entry and no creating
3925
+ * marker, leave no tombstone, and the re-ensure would then RESURRECT a receiver for
3926
+ * an agent that asked to go dark — accepting inbound sessions for an offline agent.
3927
+ * Here the want-flag is left intact and re-checked after the stop: a concurrent stop
3928
+ * clears it, and the rebuild correctly no-ops.
3929
+ */
3930
+ async #rebuildStandingReceiver(agentName) {
3931
+ try {
3932
+ const sr = this.#standingReceivers.get(agentName);
3933
+ if (sr) {
3934
+ this.#standingReceivers.delete(agentName);
3935
+ try {
3936
+ sr.autoNat.stop();
3937
+ await sr.node.stop();
3938
+ }
3939
+ catch (err) {
3940
+ this.#logger.warn("session.standing_receiver.teardown.failed", {
3941
+ agentName,
3942
+ error: extractErrorMessage(err),
3943
+ });
3944
+ }
3945
+ }
3946
+ // The agent may have gone offline while we were stopping the old node. Its
3947
+ // want-flag is the authority — never resurrect a receiver it disowned.
3948
+ if (!this.#agentsWantingReceiver.has(agentName) || this.#shuttingDown)
3949
+ return;
3950
+ await this.#ensureStandingReceiver(agentName);
3951
+ }
3952
+ catch (err) {
3953
+ this.#logger.warn("session.standing_receiver.reservation.rebuild.failed", {
3954
+ agentName,
3955
+ error: extractErrorMessage(err),
3956
+ });
3957
+ }
3958
+ }
3959
+ /**
3960
+ * DOD-NAT-REACHABILITY-1: circuit-relay listen addresses for an agent's known
3961
+ * relays, so its standing receiver takes reservations and becomes dialable
3962
+ * behind NAT. Sources, merged and deduped by relay peer id: the directory's
3963
+ * auth-time relay pool (freshest — first), then the persisted relay endpoints
3964
+ * of past sessions (getAgentRelayEndpoints — covers a directory that predates
3965
+ * the auth_ok extension).
3966
+ */
3967
+ #reservationCircuitAddrs(agentName) {
3968
+ let persisted;
3969
+ try {
3970
+ persisted = this.getAgentRelayEndpoints(agentName);
3971
+ }
3972
+ catch (err) {
3973
+ // No DB / unknown agent — persisted source unavailable. The directory
3974
+ // source may still serve; reachability degrades only if both are empty.
3975
+ // Logged (not swallowed): a genuine DB failure must be distinguishable
3976
+ // from "fresh agent, no history" in the reachability trail.
3977
+ this.#logger.debug("session.standing_receiver.persisted_relays.unavailable", {
3978
+ agentName,
3979
+ error: extractErrorMessage(err),
3980
+ });
3981
+ persisted = [];
3982
+ }
3983
+ const merged = new Map();
3984
+ for (const ep of [...(this.#directoryRelayEndpoints.get(agentName) ?? []), ...persisted]) {
3985
+ if (!merged.has(ep.relayPeerId))
3986
+ merged.set(ep.relayPeerId, ep);
3987
+ }
3988
+ const addrs = [];
3989
+ const relayPeerIds = [];
3990
+ for (const ep of merged.values()) {
3991
+ const base = ep.relayAddrs[0];
3992
+ if (!base)
3993
+ continue;
3994
+ const candidate = base.includes("/p2p/")
3995
+ ? `${base}/p2p-circuit`
3996
+ : `${base}/p2p/${ep.relayPeerId}/p2p-circuit`;
3997
+ // These addresses are built from DIRECTORY-supplied endpoints — data from off
3998
+ // this machine. A malformed one throws inside libp2p node construction, which
3999
+ // would take the standing receiver down entirely and leave the agent deaf to
4000
+ // ALL inbound (worse than the defect this fixes). A bad endpoint must cost one
4001
+ // relay, never the receiver.
4002
+ if (!isValidMultiaddr(candidate)) {
4003
+ this.#logger.warn("session.standing_receiver.relay_endpoint.invalid", {
4004
+ agentName,
4005
+ relayPeerId: ep.relayPeerId,
4006
+ addr: candidate,
4007
+ });
4008
+ continue;
4009
+ }
4010
+ addrs.push(candidate);
4011
+ relayPeerIds.push(ep.relayPeerId);
4012
+ }
4013
+ return { addrs, relayPeerIds };
4014
+ }
4015
+ /**
4016
+ * Start the standing receiver's libp2p node, with reservations if they can be had
4017
+ * QUICKLY, and without them if they cannot.
4018
+ *
4019
+ * THE INVARIANT — learned the hard way, live: **standing-receiver creation must
4020
+ * NEVER be gated on relay reachability.** libp2p's circuit listener awaits a live
4021
+ * connection to each relay before start() resolves, and it does not time out. Put
4022
+ * three relays in the initial listen set, have one of them not answer from this
4023
+ * network, and start() hangs FOREVER: no created event, no failure, no retry, no
4024
+ * alarm — the agent simply has no receiver and is deaf to ALL inbound, including
4025
+ * the direct path that worked before reservations existed. That is strictly worse
4026
+ * than the NAT defect this whole line exists to fix.
4027
+ *
4028
+ * So the reservation attempt is RACED against a deadline. If it wins, we get a
4029
+ * node that is both directly dialable and reachable through the relays. If it does
4030
+ * not, we fall back to a plain TCP receiver — the agent is reachable on the direct
4031
+ * path immediately — and the standing receiver is rebuilt with reservations later,
4032
+ * when the directory next hands us endpoints (setDirectoryRelayEndpoints → the
4033
+ * rebuild-if-deaf path). Degraded, loud, and never deaf.
4034
+ */
4035
+ async #startReceiverNode(agentName, sessionId, gater, circuitAddrs, correlationId) {
4036
+ if (circuitAddrs.length > 0) {
4037
+ const withReservations = await this.#factory.createNode({
4038
+ sessionId,
4039
+ connectionGater: gater,
4040
+ nodeType: "standing_receiver",
4041
+ circuitRelayListenAddrs: circuitAddrs,
4042
+ });
4043
+ let timer;
4044
+ const timedOut = Symbol("reservation_timeout");
4045
+ try {
4046
+ const outcome = await Promise.race([
4047
+ withReservations.start().then(() => "started"),
4048
+ new Promise((resolve) => {
4049
+ timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
4050
+ }),
4051
+ ]);
4052
+ if (outcome === "started")
4053
+ return withReservations;
4054
+ }
4055
+ finally {
4056
+ if (timer !== undefined)
4057
+ clearTimeout(timer);
4058
+ }
4059
+ // The deadline passed with start() still pending — at least one relay is not
4060
+ // answering. Abandon this node (its start may still be parked on a dial; stop()
4061
+ // is best-effort and must not itself block the fallback) and come up TCP-only.
4062
+ this.#logger.warn("session.standing_receiver.reservation.timeout", {
4063
+ agentName,
4064
+ relayCount: circuitAddrs.length,
4065
+ timeoutMs: this.#srReservationTimeoutMs,
4066
+ reason: "at_least_one_relay_did_not_answer_in_time",
4067
+ correlationId,
4068
+ });
4069
+ void Promise.resolve()
4070
+ .then(() => withReservations.stop())
4071
+ .catch(() => { });
4072
+ }
4073
+ const plain = await this.#factory.createNode({
4074
+ sessionId,
4075
+ connectionGater: gater,
4076
+ nodeType: "standing_receiver",
4077
+ });
4078
+ await plain.start();
4079
+ return plain;
4080
+ }
3874
4081
  /** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
3875
4082
  async #tryCreateStandingReceiver(agentName, correlationId) {
3876
4083
  const sessionId = `standing_receiver_${randomUUID()}`;
@@ -3879,13 +4086,23 @@ export class SessionNodeManager {
3879
4086
  allowedPeerId: null, // open — counterparty unknown at creation time
3880
4087
  logger: this.#logger,
3881
4088
  });
4089
+ // DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
4090
+ // peers are allowed OUTBOUND on the gater up front, so reservation refreshes
4091
+ // keep working after the receiver is claimed and setAllowedPeer() narrows
4092
+ // the inbound gate to the session counterparty.
4093
+ const reservations = this.#reservationCircuitAddrs(agentName);
4094
+ for (const relayPeerId of reservations.relayPeerIds) {
4095
+ gater.setAllowedOutboundPeer(relayPeerId);
4096
+ }
3882
4097
  let node;
3883
4098
  try {
3884
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "standing_receiver" });
3885
- await node.start();
4099
+ node = await this.#startReceiverNode(agentName, sessionId, gater, reservations.addrs, correlationId);
3886
4100
  }
3887
4101
  catch (err) {
3888
- const error = err instanceof Error ? err.message : String(err);
4102
+ // extractErrorMessage, NOT String(err): the transport throws structured
4103
+ // plain objects ({ reason, message }), and String() destroys both into
4104
+ // "[object Object]" — the loud failure must carry its cause.
4105
+ const error = extractErrorMessage(err);
3889
4106
  this.#logger.error("session.node.create.failed", {
3890
4107
  sessionId,
3891
4108
  agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
@@ -3931,6 +4148,25 @@ export class SessionNodeManager {
3931
4148
  sessionPeerId: node.getPeerId(),
3932
4149
  correlationId,
3933
4150
  });
4151
+ // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
4152
+ // up? circuitAddrs === 0 with reservations requested means every relay
4153
+ // refused/was unreachable — the agent is deaf to NAT'd initiators (public
4154
+ // ones can still connect directly). That must be LOUD, not a quiet shrug.
4155
+ const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
4156
+ this.#logger.info("session.standing_receiver.reachability", {
4157
+ agentName,
4158
+ circuitAddrs,
4159
+ reservationsRequested: reservations.addrs.length,
4160
+ correlationId,
4161
+ });
4162
+ if (reservations.addrs.length > 0 && circuitAddrs === 0) {
4163
+ this.#logger.warn("session.standing_receiver.reservation.none", {
4164
+ agentName,
4165
+ reservationsRequested: reservations.addrs.length,
4166
+ relayPeerIds: reservations.relayPeerIds,
4167
+ correlationId,
4168
+ });
4169
+ }
3934
4170
  return { outcome: "installed" };
3935
4171
  }
3936
4172
  /**
@@ -3946,6 +4182,10 @@ export class SessionNodeManager {
3946
4182
  async removeStandingReceiverForAgent(agentName) {
3947
4183
  // M8B F14: the agent no longer wants a receiver — disarm the teardown re-arm.
3948
4184
  this.#agentsWantingReceiver.delete(agentName);
4185
+ // The directory hands these out at signaling-auth time, so a re-started agent
4186
+ // gets a fresh set on its next connect — holding the old ones would keep a
4187
+ // retired agent's relay list alive for the daemon's lifetime.
4188
+ this.#directoryRelayEndpoints.delete(agentName);
3949
4189
  const sr = this.#standingReceivers.get(agentName);
3950
4190
  if (!sr) {
3951
4191
  // L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no