@cello-protocol/daemon 0.0.54 → 0.0.55

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";
@@ -2455,6 +2456,15 @@ export class SessionNodeManager {
2455
2456
  if (addrs.length === 0) {
2456
2457
  return { ok: false, reason: "no_counterparty_addrs", error: "the assignment carried no counterparty session addrs to dial" };
2457
2458
  }
2459
+ // DOD-NAT-REACHABILITY-1: a /p2p-circuit counterparty address is dialed
2460
+ // THROUGH its relay, so the gater must admit that relay peer OUTBOUND. The
2461
+ // relay id is embedded in the address, which arrived inside the FROST-signed
2462
+ // assignment — the same authorization rail as the assigned witness relay.
2463
+ for (const addr of addrs) {
2464
+ const viaRelay = addr.match(/\/p2p\/([^/]+)\/p2p-circuit/);
2465
+ if (viaRelay)
2466
+ entry.gater.setAllowedOutboundPeer(viaRelay[1]);
2467
+ }
2458
2468
  let lastError = "";
2459
2469
  for (const addr of addrs) {
2460
2470
  try {
@@ -2467,8 +2477,11 @@ export class SessionNodeManager {
2467
2477
  return { ok: true };
2468
2478
  }
2469
2479
  catch (err) {
2470
- // error.message extracted never [object Object]; try the next addr.
2471
- lastError = err instanceof Error ? err.message : String(err);
2480
+ // extractErrorMessage handles the transport's structured plain-object
2481
+ // throws (dial() never throws Error instances) the old
2482
+ // `instanceof Error` idiom logged "[object Object]" on every dial
2483
+ // failure; try the next addr.
2484
+ lastError = extractErrorMessage(err);
2472
2485
  }
2473
2486
  }
2474
2487
  this.#logger.warn("session.transport.connect.failed", {
@@ -3871,6 +3884,131 @@ export class SessionNodeManager {
3871
3884
  this.#standingReceiverCreating.delete(agentName);
3872
3885
  }
3873
3886
  }
3887
+ /**
3888
+ * DOD-NAT-REACHABILITY-1 (Phase 2): relay endpoints the DIRECTORY handed this
3889
+ * agent at signaling-auth time — the freshest, health-filtered view of the
3890
+ * relay pool, and the only source a FRESH agent (no session history) has.
3891
+ */
3892
+ #directoryRelayEndpoints = new Map();
3893
+ /**
3894
+ * DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
3895
+ * for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
3896
+ * reconnect). If the agent's standing receiver is up but holds NO reservation —
3897
+ * the agent-online ensure raced ahead of auth_ok, or every relay was down at
3898
+ * create time — rebuild it now so the agent becomes dialable without waiting
3899
+ * for a session handoff that (being unreachable) would never come.
3900
+ */
3901
+ setDirectoryRelayEndpoints(agentName, endpoints) {
3902
+ this.#directoryRelayEndpoints.set(agentName, endpoints);
3903
+ if (endpoints.length === 0 || this.#shuttingDown)
3904
+ return;
3905
+ const sr = this.#standingReceivers.get(agentName);
3906
+ if (!sr)
3907
+ return; // not ensured yet — the coming ensure reads the map
3908
+ if (sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit")))
3909
+ return; // already reserved
3910
+ this.#logger.info("session.standing_receiver.reservation.rebuild", {
3911
+ agentName,
3912
+ relayPeerIds: endpoints.map((e) => e.relayPeerId),
3913
+ });
3914
+ void this.#rebuildStandingReceiver(agentName);
3915
+ }
3916
+ /**
3917
+ * Replace an agent's reservation-less standing receiver with one that reserves.
3918
+ *
3919
+ * Deliberately NOT removeStandingReceiverForAgent()+ensureStandingReceiverForAgent():
3920
+ * the public remove CLEARS #agentsWantingReceiver, so a cello_stop_agent landing in
3921
+ * the window while node.stop() is awaited would find no map entry and no creating
3922
+ * marker, leave no tombstone, and the re-ensure would then RESURRECT a receiver for
3923
+ * an agent that asked to go dark — accepting inbound sessions for an offline agent.
3924
+ * Here the want-flag is left intact and re-checked after the stop: a concurrent stop
3925
+ * clears it, and the rebuild correctly no-ops.
3926
+ */
3927
+ async #rebuildStandingReceiver(agentName) {
3928
+ try {
3929
+ const sr = this.#standingReceivers.get(agentName);
3930
+ if (sr) {
3931
+ this.#standingReceivers.delete(agentName);
3932
+ try {
3933
+ sr.autoNat.stop();
3934
+ await sr.node.stop();
3935
+ }
3936
+ catch (err) {
3937
+ this.#logger.warn("session.standing_receiver.teardown.failed", {
3938
+ agentName,
3939
+ error: extractErrorMessage(err),
3940
+ });
3941
+ }
3942
+ }
3943
+ // The agent may have gone offline while we were stopping the old node. Its
3944
+ // want-flag is the authority — never resurrect a receiver it disowned.
3945
+ if (!this.#agentsWantingReceiver.has(agentName) || this.#shuttingDown)
3946
+ return;
3947
+ await this.#ensureStandingReceiver(agentName);
3948
+ }
3949
+ catch (err) {
3950
+ this.#logger.warn("session.standing_receiver.reservation.rebuild.failed", {
3951
+ agentName,
3952
+ error: extractErrorMessage(err),
3953
+ });
3954
+ }
3955
+ }
3956
+ /**
3957
+ * DOD-NAT-REACHABILITY-1: circuit-relay listen addresses for an agent's known
3958
+ * relays, so its standing receiver takes reservations and becomes dialable
3959
+ * behind NAT. Sources, merged and deduped by relay peer id: the directory's
3960
+ * auth-time relay pool (freshest — first), then the persisted relay endpoints
3961
+ * of past sessions (getAgentRelayEndpoints — covers a directory that predates
3962
+ * the auth_ok extension).
3963
+ */
3964
+ #reservationCircuitAddrs(agentName) {
3965
+ let persisted;
3966
+ try {
3967
+ persisted = this.getAgentRelayEndpoints(agentName);
3968
+ }
3969
+ catch (err) {
3970
+ // No DB / unknown agent — persisted source unavailable. The directory
3971
+ // source may still serve; reachability degrades only if both are empty.
3972
+ // Logged (not swallowed): a genuine DB failure must be distinguishable
3973
+ // from "fresh agent, no history" in the reachability trail.
3974
+ this.#logger.debug("session.standing_receiver.persisted_relays.unavailable", {
3975
+ agentName,
3976
+ error: extractErrorMessage(err),
3977
+ });
3978
+ persisted = [];
3979
+ }
3980
+ const merged = new Map();
3981
+ for (const ep of [...(this.#directoryRelayEndpoints.get(agentName) ?? []), ...persisted]) {
3982
+ if (!merged.has(ep.relayPeerId))
3983
+ merged.set(ep.relayPeerId, ep);
3984
+ }
3985
+ const addrs = [];
3986
+ const relayPeerIds = [];
3987
+ for (const ep of merged.values()) {
3988
+ const base = ep.relayAddrs[0];
3989
+ if (!base)
3990
+ continue;
3991
+ const candidate = base.includes("/p2p/")
3992
+ ? `${base}/p2p-circuit`
3993
+ : `${base}/p2p/${ep.relayPeerId}/p2p-circuit`;
3994
+ // These addresses are built from DIRECTORY-supplied endpoints — data from off
3995
+ // this machine. A malformed one throws inside libp2p node construction, which
3996
+ // would take the standing receiver down entirely and leave the agent deaf to
3997
+ // ALL inbound (worse than the defect this fixes). A bad endpoint must cost one
3998
+ // relay, never the receiver.
3999
+ if (!isValidMultiaddr(candidate)) {
4000
+ this.#logger.warn("session.standing_receiver.relay_endpoint.invalid", {
4001
+ agentName,
4002
+ relayPeerId: ep.relayPeerId,
4003
+ addr: candidate,
4004
+ });
4005
+ continue;
4006
+ }
4007
+ addrs.push(candidate);
4008
+ relayPeerIds.push(ep.relayPeerId);
4009
+ }
4010
+ return { addrs, relayPeerIds };
4011
+ }
3874
4012
  /** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
3875
4013
  async #tryCreateStandingReceiver(agentName, correlationId) {
3876
4014
  const sessionId = `standing_receiver_${randomUUID()}`;
@@ -3879,13 +4017,29 @@ export class SessionNodeManager {
3879
4017
  allowedPeerId: null, // open — counterparty unknown at creation time
3880
4018
  logger: this.#logger,
3881
4019
  });
4020
+ // DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
4021
+ // peers are allowed OUTBOUND on the gater up front, so reservation refreshes
4022
+ // keep working after the receiver is claimed and setAllowedPeer() narrows
4023
+ // the inbound gate to the session counterparty.
4024
+ const reservations = this.#reservationCircuitAddrs(agentName);
4025
+ for (const relayPeerId of reservations.relayPeerIds) {
4026
+ gater.setAllowedOutboundPeer(relayPeerId);
4027
+ }
3882
4028
  let node;
3883
4029
  try {
3884
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "standing_receiver" });
4030
+ node = await this.#factory.createNode({
4031
+ sessionId,
4032
+ connectionGater: gater,
4033
+ nodeType: "standing_receiver",
4034
+ ...(reservations.addrs.length > 0 ? { circuitRelayListenAddrs: reservations.addrs } : {}),
4035
+ });
3885
4036
  await node.start();
3886
4037
  }
3887
4038
  catch (err) {
3888
- const error = err instanceof Error ? err.message : String(err);
4039
+ // extractErrorMessage, NOT String(err): the transport throws structured
4040
+ // plain objects ({ reason, message }), and String() destroys both into
4041
+ // "[object Object]" — the loud failure must carry its cause.
4042
+ const error = extractErrorMessage(err);
3889
4043
  this.#logger.error("session.node.create.failed", {
3890
4044
  sessionId,
3891
4045
  agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
@@ -3931,6 +4085,25 @@ export class SessionNodeManager {
3931
4085
  sessionPeerId: node.getPeerId(),
3932
4086
  correlationId,
3933
4087
  });
4088
+ // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
4089
+ // up? circuitAddrs === 0 with reservations requested means every relay
4090
+ // refused/was unreachable — the agent is deaf to NAT'd initiators (public
4091
+ // ones can still connect directly). That must be LOUD, not a quiet shrug.
4092
+ const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
4093
+ this.#logger.info("session.standing_receiver.reachability", {
4094
+ agentName,
4095
+ circuitAddrs,
4096
+ reservationsRequested: reservations.addrs.length,
4097
+ correlationId,
4098
+ });
4099
+ if (reservations.addrs.length > 0 && circuitAddrs === 0) {
4100
+ this.#logger.warn("session.standing_receiver.reservation.none", {
4101
+ agentName,
4102
+ reservationsRequested: reservations.addrs.length,
4103
+ relayPeerIds: reservations.relayPeerIds,
4104
+ correlationId,
4105
+ });
4106
+ }
3934
4107
  return { outcome: "installed" };
3935
4108
  }
3936
4109
  /**
@@ -3946,6 +4119,10 @@ export class SessionNodeManager {
3946
4119
  async removeStandingReceiverForAgent(agentName) {
3947
4120
  // M8B F14: the agent no longer wants a receiver — disarm the teardown re-arm.
3948
4121
  this.#agentsWantingReceiver.delete(agentName);
4122
+ // The directory hands these out at signaling-auth time, so a re-started agent
4123
+ // gets a fresh set on its next connect — holding the old ones would keep a
4124
+ // retired agent's relay list alive for the daemon's lifetime.
4125
+ this.#directoryRelayEndpoints.delete(agentName);
3949
4126
  const sr = this.#standingReceivers.get(agentName);
3950
4127
  if (!sr) {
3951
4128
  // L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no