agent-comms 8.15.8 → 8.15.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 (184) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * WireMeshTransport — MeshTransport implemented over wire-mesh-core, carrying the existing MeshMessage union as an opaque payload rather than inventing new wire semantics. This is the P2 substrate swap's own deliverable: MeshStore, CommsTool, and every existing agent/room/message behaviour above the transport are completely unaffected -- only how bytes move between peers changes.
3
+ *
4
+ * Every agent-comms MeshMessage rides as a single namespaced manage-command verb (FRAME_VERB) under one flat scope (FRAME_SCOPE); the message itself is carried opaquely in the command's own params.message field. This needs no spec/CDDL change: manage-command-params is already an open `{* tstr => any}` socket for exactly this purpose.
5
+ *
6
+ * Peer identity is authenticated at the transport layer, not asserted on the wire: createTlsTransport verifies a peer's presented certificate cryptographically and exposes the result as Connection.peerDeviceId before any MeshMessage is ever exchanged, which is what lets the `pong` self-identification message retire entirely -- there is nothing left for it to prove that the connection itself hasn't already proven.
7
+ *
8
+ * connect_request's accept/reject flow maps onto sendManageRequest's own request/response round trip directly, rather than a separate pair of connect_accepted/connect_rejected messages: manage-request-frame already tolerates arbitrary latency between a request and its response, so "waiting for a human to approve" is simply a manage-response that hasn't been sent yet, not a protocol gap needing its own mechanism. connect_request therefore holds its own IncomingManageRequest open (rather than responding immediately) until acceptConnection/rejectConnection is actually called.
9
+ */
10
+
11
+ import { createTlsTransport } from "wire-mesh-core/adapters/tls-transport";
12
+ import {
13
+ ROOM_JOIN_APPROVAL_TIMEOUT_MS,
14
+ ROOM_REQUEST_TIMEOUT_MS,
15
+ manageRequestTimeoutMs,
16
+ } from "./request-timeouts.js";
17
+ import { dialRemote } from "./ws-dial.js";
18
+ import { HubSession } from "./hub-session.js";
19
+ import { PRESENCE_READVERTISE_INTERVAL_MS } from "./gossip-extensions.js";
20
+ import { GatewayTrust, type GatewayTrustReader } from "./gateway-trust.js";
21
+ import {
22
+ advertisedListenerAddresses,
23
+ listenerPort,
24
+ listTrackedListeners,
25
+ registerDefaultListener,
26
+ registerListener,
27
+ unregisterListener,
28
+ type TrackedListener,
29
+ } from "./listener-registry.js";
30
+ import {
31
+ findPresenceAdvert,
32
+ KnownDevices,
33
+ listKnownDevicesEntries,
34
+ mergeAndAnnounceReachable,
35
+ readvertiseGossip,
36
+ startGossipInterval,
37
+ } from "./gossip-directory.js";
38
+ import { directoryAdmission, staysAdmitted } from "./directory-admission.js";
39
+ import { routeRoomRequestViaHub } from "./hub-forwarding.js";
40
+ import { ElectionSessions } from "./election-sessions.js";
41
+ import { isLoopbackAddress } from "./loopback-address.js";
42
+ import type { AgentStatus } from "./types.js";
43
+ import { RelayRole } from "./relay-role.js";
44
+ import { ALL_INTERFACES_HOST } from "./relay-server.js";
45
+ import {
46
+ DEFAULT_PENDING_CONNECTION_TIMEOUT_MS,
47
+ expirePendingConnection,
48
+ type ConnectionDecision,
49
+ type PendingConnection,
50
+ } from "./pending-connection.js";
51
+ import {
52
+ acceptMeshSession,
53
+ type AcceptedMeshSession,
54
+ type ManageOutcome,
55
+ } from "wire-mesh-core/domain/mesh-session";
56
+ import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
57
+ import {
58
+ computeMeshGraph,
59
+ handlePathTraceRequest,
60
+ traceMeshPath,
61
+ } from "./mesh-graph.js";
62
+ import {
63
+ accountReplicationFrom,
64
+ DataFrameRouter,
65
+ hubDataChannel,
66
+ } from "./data-frame-routing.js";
67
+ import type {
68
+ CapabilityScope,
69
+ CapabilityToken,
70
+ CoordinatorFrame,
71
+ DataHaveFrame,
72
+ DataRequestFrame,
73
+ Frame,
74
+ ManageCommand,
75
+ PeerAdvert,
76
+ RevocationEntry,
77
+ } from "wire-mesh-core/generated/protocol";
78
+ import type {
79
+ Connection,
80
+ Listener,
81
+ Transport,
82
+ } from "wire-mesh-core/ports/transport";
83
+ import type { MeshMessage, PeerInfo } from "./wire-protocol.js";
84
+ import type {
85
+ ConnectionHandle,
86
+ ListenerInfo,
87
+ ListenerPolicy,
88
+ MeshGraph,
89
+ MeshTransport,
90
+ MeshTraceResult,
91
+ TransportEvents,
92
+ } from "./transport.js";
93
+ import type { PeerIdentity } from "./identity.js";
94
+ import { toIdentityPort } from "./wire-mesh-identity.js";
95
+ import { PeerLinkError } from "./peer-link-error.js";
96
+ import {
97
+ DATA_SERVER_ACCEPT,
98
+ DEFAULT_LISTENER_ACCEPT,
99
+ operatorListenerAccept,
100
+ type AcceptOptions,
101
+ type ConnectToRemoteOptions,
102
+ type WireMeshTransportOptions,
103
+ } from "./wire-mesh-transport-options.js";
104
+ import {
105
+ createRoomRouter,
106
+ extractMessage,
107
+ type RoomRouter,
108
+ } from "./room-router.js";
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Constants
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const COORDINATOR_HOST = "127.0.0.1";
115
+
116
+ /** This project's own namespaced domain (registrant "exadev.io", local name "agent-comms-v1"), matching namespaced-domain-id's "<registrant>/<local-name>" shape -- registry/core-domains.md's own recommended pattern for a third party. Exported for test use only: a security test simulating a hostile client that skips connect_request needs to construct a well-formed frame under the same domain/verb/scope this transport itself listens on, rather than duplicating these as separately-maintained magic strings that could silently drift from the real values. */
117
+ export const DOMAIN = "exadev.io/agent-comms-v1";
118
+
119
+ /** One verb for the entire MeshMessage union: this phase carries every message opaquely rather than modelling each arm as its own verb, which is core/room's own job once P3 gives this substrate real room semantics. */
120
+ export const FRAME_VERB = "exadev.io/agent-comms-v1:frame";
121
+
122
+ /** No finer-grained authorisation model exists above "you're an approved member of this mesh" at this phase -- once a session is past the quarantine gate (see handleAcceptedConnection), it's trusted for every message this domain carries. */
123
+ export const FRAME_SCOPE: Readonly<CapabilityScope> = {
124
+ kind: "agent-comms-mesh",
125
+ };
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Message carriage
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export function buildCommand(message: MeshMessage): ManageCommand {
132
+ return { verb: FRAME_VERB, params: { message } };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // WireMeshTransport
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export class WireMeshTransport implements MeshTransport {
140
+ private readonly wireTransport: Transport;
141
+ private readonly events: TransportEvents;
142
+ private readonly identityReady: Promise<
143
+ Awaited<ReturnType<typeof toIdentityPort>>
144
+ >;
145
+
146
+ private _dataPort = 0;
147
+ private _isCoordinator = false;
148
+ private shutDown = false;
149
+
150
+ /** A method call, not a direct property read -- deliberately, so TS's control-flow narrowing (which does track `this.shutDown` as staying false for the rest of a function once an early `if (this.shutDown) return` has passed, with no awareness that an `await` in between gave a concurrent shutdown() call the chance to flip it) doesn't treat every later re-check in the same async function as unreachable. Each of these re-checks is real: shutdown() can run at any time this function is suspended on an await. */
151
+ private isShuttingDown(): boolean {
152
+ return this.shutDown;
153
+ }
154
+
155
+ // -- Data server (accepts data connections from already-known peers) --
156
+ private dataListener: Listener | undefined;
157
+
158
+ // -- Coordinator listeners (the well-known bootstrap port, plus any additional adapters addListener creates) --
159
+ private readonly coordinatorListeners = new Map<string, TrackedListener>();
160
+ private defaultListenerId: string | undefined;
161
+
162
+ // -- The session dialled via connectToCoordinator, when this instance is not itself the coordinator -- and that same coordinator's own device-id hex, set alongside it: coordinatorPeerId reports it, so PeerLifecycle can tell the coordinator's departure from an ordinary peer's. The session is dropped when it closes and the hex when this side takes the coordinator role itself, so coordinatorPeerId below names a coordinator this instance genuinely still answers to.
163
+ private coordinatorSession: AcceptedMeshSession | undefined;
164
+ private coordinatorDeviceHex: string | undefined;
165
+
166
+ // -- The hub relay mode (agent-comms#151), owning its own file under the max-lines cap: see hub-session.ts for the full connection model.
167
+ readonly hub: HubSession;
168
+
169
+ // -- Which relay the hub session is held over, and the relay this store serves while it holds the elected coordinator role and an uplink (agent-comms#342): see relay-role.ts.
170
+ readonly relay: RelayRole;
171
+
172
+ // -- Every live session, keyed by the peer's authenticated device-id hex (== ConnectionHandle.id) -- covers coordinator-client, coordinator-accepted, and peer data sessions alike, since send()/broadcast() must reach whichever kind of session a peer happens to be reachable through. A single-slot-per-key map by construction: mesh formation genuinely establishes TWO independent sessions to the same peer (see the dataDials comment below), and the second one registered here simply overwrites the first as far as addressing goes -- fine for send()/broadcast() (either socket reaches the same peer), but NOT fine for shutdown, which must close every live session regardless of whether it's still reachable through this map. allSessions below exists specifically so shutdown never leaks the one this map's overwrite silently stopped tracking.
173
+ private readonly peerSessions = new Map<string, AcceptedMeshSession>();
174
+
175
+ // -- Every live session this transport has ever created, accepted or dialled, for shutdown's own use only -- never used for addressing (peerSessions is), so it never loses track of one session to another sharing the same peer id.
176
+ private readonly allSessions = new Set<AcceptedMeshSession>();
177
+
178
+ // The machine-local sessions coordinator claims travel over (agent-comms#341): see election-sessions.ts.
179
+ private readonly electionSessions: ElectionSessions;
180
+
181
+ // -- Every device this side has heard gossip from, across every session's own directory and the hub's, keyed by device-id hex: the mesh-wide aggregation room discovery and agent listing read. See KnownDevices for which entries it keeps and for how long.
182
+ private readonly knownDevices = new KnownDevices((deviceHex) =>
183
+ staysAdmitted(this.gatewayTrust, deviceHex),
184
+ );
185
+
186
+ /** Every device this side has ever heard gossip from, mesh-wide -- not just its own directly-connected peers -- with each one's own latest full advert (addresses, snapshot-seconds, and every open-extension field such as presence/status). See gossip-directory.ts's own listKnownDevicesEntries for the actual implementation, kept out of this already-large file. */
187
+ listKnownDevices(): readonly {
188
+ deviceId: string;
189
+ advert: Readonly<PeerAdvert>;
190
+ }[] {
191
+ return listKnownDevicesEntries(this.knownDevices.current());
192
+ }
193
+
194
+ /** Assembles this side's own best-effort view of the mesh's connection graph (agent-comms#199) -- see mesh-graph.ts's own computeMeshGraph for the actual implementation, kept out of this already-large file. */
195
+ meshGraph(): MeshGraph {
196
+ return computeMeshGraph(this.knownDevices.current());
197
+ }
198
+
199
+ /** Sends path.trace to targetDeviceHex (agent-comms#199) -- see mesh-graph.ts's own traceMeshPath for the actual direct-or-hub fallback, kept out of this already-large file. */
200
+ async meshTrace(
201
+ targetDeviceHex: string,
202
+ timeoutMs?: number,
203
+ ): Promise<MeshTraceResult> {
204
+ return traceMeshPath(
205
+ this.peerSessions,
206
+ this.hub,
207
+ targetDeviceHex,
208
+ timeoutMs,
209
+ );
210
+ }
211
+
212
+ /** Registers a session in both peerSessions (addressing -- last one in for a given peer wins) and allSessions (shutdown -- every session, always). */
213
+ private trackSession(key: string, session: AcceptedMeshSession): void {
214
+ this.peerSessions.set(key, session);
215
+ this.allSessions.add(session);
216
+ }
217
+
218
+ // -- Peers this side has dialled via connectToPeer specifically (in flight or established) -- deliberately separate from peerSessions, which also holds sessions this side reached the SAME peer through for an unrelated reason (most concretely, the coordinator-client session connectToCoordinator opens to the coordinator's own device-id). Mesh formation deliberately establishes a second, independent connection in each direction so each side's own acceptor can push its own state (see mesh-store.ts's own handlePeerConnected), so connectToPeer's "don't dial twice" guard must not be satisfied by an unrelated session that merely happens to share the same peer ID.
219
+ private readonly dataDials = new Set<string>();
220
+
221
+ // -- connect_request frames awaiting a human accept/reject decision, keyed by the requester's device-id hex --
222
+ private readonly pendingConnections = new Map<string, PendingConnection>();
223
+
224
+ // -- The single consumer of every approved session's incomingManageRequests, once quarantine (if any) is past. Owns verb dispatch (the legacy opaque frame, plus whichever real core/room verbs later phases register handlers for) -- this transport itself no longer decodes or routes a MeshMessage at all beyond handing a session off here.
225
+ private readonly roomRouter: RoomRouter;
226
+
227
+ private readonly pendingConnectionTimeoutMs: number;
228
+ private readonly roomJoinApprovalTimeoutMs: number;
229
+
230
+ /** Reads this side's own current AgentStatus for the next gossip re-advertisement tick -- a pull, not a push, so MeshStore never needs to reach into this transport's internals on every status change (see updateAgent/setAgentOffline, which patch MeshStore's own agents map and let the next tick pick it up). undefined when no presence source was wired in (every existing construction site that predates this feature). */
231
+ private readonly getCurrentPresence: WireMeshTransportOptions["getCurrentPresence"];
232
+ /** Reads this side's own currently-hosted public/private rooms for the next gossip re-advertisement tick, the same pull-not-push shape getCurrentPresence already established -- createRoom/destroyRoom patch MeshStore's own rooms map and let the next tick pick it up, rather than pushing an update here on every mutation. undefined when no hosted-rooms source was wired in. */
233
+ private readonly getHostedRooms: WireMeshTransportOptions["getHostedRooms"];
234
+ private gossipInterval: ReturnType<typeof setInterval> | undefined;
235
+
236
+ /** Reads this side's own gossip-safe agent-identity advert for the next gossip re-advertisement tick, the same pull-not-push shape getCurrentPresence/getHostedRooms already established. undefined when no agent-identity source was wired in, or when MeshStore's own getter decides this agent shouldn't advertise itself this way right now (e.g. not "visible", or no self-agent record yet). */
237
+ private readonly getSelfAgentAdvert: WireMeshTransportOptions["getSelfAgentAdvert"];
238
+
239
+ /** Reads this side's own currently-running cc-peer package version for the next gossip re-advertisement tick (agent-comms#198). Unlike getCurrentPresence/getHostedRooms/getSelfAgentAdvert (constructor-injected, since MeshStore already has a real value for each at construction time), this is a plain mutable field set post-construction -- bridge-mesh.ts assigns it right after building this transport, mirroring MeshStore's own onDelivery/onError/onCoordinatorRoleChanged convention, so this transport doesn't need its own constructor parameter for a fact only two of many bridges ever have. undefined for every bridge that never loads cc-peer at all. */
240
+ getCcPeerVersion: (() => string | undefined) | undefined;
241
+
242
+ /** Answers, stores and offers this side's core/data frames: the room-notice oplog over dataStorage, and the account's replicated grant ledger (agent-comms#344). See DataFrameRouter. */
243
+ private readonly dataFrames: DataFrameRouter;
244
+
245
+ /** The cross-machine trust boundary (agent-comms#156): gates outbound gossip advertisement (hasAny), inbound directory merge (bare-device trust, a principal's own device, or a verified membership proof, wired into HubSession as admitEntries), the legacy per-device frame path (isTrusted, bare-device trust only), and outbound targeted hub requests (isReachable); see GatewayTrust's own class doc. A real room-domain manage-request relayed through the hub is never gated on this at all since agent-comms#192: hub-session.ts's own dispatchHubRequest relies purely on that verb's own capability-token verification instead. Defaults to a fresh, empty (deny-all) instance when no caller wires one in, matching every existing construction site that predates this feature. */
246
+ private readonly gatewayTrust: GatewayTrustReader;
247
+
248
+ /** See WireMeshTransportOptions's own doc comment -- all eight fields are optional and bundled into one `options` parameter, so a caller needing only gatewayTrust no longer has to pass `undefined` for every one before it. */
249
+ constructor(
250
+ events: Readonly<TransportEvents>,
251
+ identity: Readonly<PeerIdentity>,
252
+ options?: WireMeshTransportOptions,
253
+ ) {
254
+ const {
255
+ roomVerbHandlers,
256
+ pendingConnectionTimeoutMs = DEFAULT_PENDING_CONNECTION_TIMEOUT_MS,
257
+ roomJoinApprovalTimeoutMs = ROOM_JOIN_APPROVAL_TIMEOUT_MS,
258
+ getCurrentPresence,
259
+ presenceReadvertiseIntervalMs = PRESENCE_READVERTISE_INTERVAL_MS,
260
+ getHostedRooms,
261
+ dataStorage,
262
+ getSelfAgentAdvert,
263
+ accountReplication,
264
+ gatewayTrust = new GatewayTrust(),
265
+ verifyMembership,
266
+ relayListenHost = ALL_INTERFACES_HOST,
267
+ } = options ?? {};
268
+ this.events = events;
269
+ this.wireTransport = createTlsTransport({
270
+ certificatePem: identity.certificate,
271
+ privateKeyPem: identity.privateKey,
272
+ });
273
+ this.identityReady = toIdentityPort(identity);
274
+ this.gatewayTrust = gatewayTrust;
275
+ this.electionSessions = new ElectionSessions(events);
276
+ this.relay = new RelayRole({
277
+ listenHost: relayListenHost,
278
+ ownDeviceHex: deviceIdToHex(Uint8Array.from(identity.deviceId)),
279
+ knownDevices: this.knownDevices,
280
+ machineLocalPeers: this.electionSessions,
281
+ gatewayTrust,
282
+ readvertise: () => {
283
+ readvertiseGossip(this.gossipOptions());
284
+ },
285
+ events,
286
+ });
287
+ // Built before this.hub below (roomRouter has no dependency on it) so the hub can be wired with a direct this.roomRouter.handleRelayedRequest reference rather than a lazy closure. path.trace (agent-comms#199) is always registered here, ahead of any caller-supplied roomVerbHandlers, since every real construction site wants it answered identically regardless of which room verbs it registers.
288
+ this.roomRouter = createRoomRouter({
289
+ events,
290
+ handlers: { "path.trace": handlePathTraceRequest, ...roomVerbHandlers },
291
+ });
292
+ this.roomJoinApprovalTimeoutMs = roomJoinApprovalTimeoutMs;
293
+ this.dataFrames = new DataFrameRouter({
294
+ dataStorage,
295
+ account: accountReplicationFrom(accountReplication, (deviceHex) =>
296
+ this.knownDevices.current().get(deviceHex),
297
+ ),
298
+ peerSessions: this.peerSessions,
299
+ onError: this.events.onError,
300
+ hub: hubDataChannel(() => this.hub),
301
+ announceIntervalMs: presenceReadvertiseIntervalMs,
302
+ });
303
+ this.hub = new HubSession({
304
+ roomJoinApprovalTimeoutMs,
305
+ identityReady: this.identityReady,
306
+ events: this.events,
307
+ isShuttingDown: this.isShuttingDown.bind(this),
308
+ advertisedAddresses: this.advertisedAddresses,
309
+ onFrame: async (conn, frame) => this.handleDataFrame(conn, frame),
310
+ onRelayedDataFrame: this.dataFrames.routeRelayed,
311
+ trackForShutdown: (session) => {
312
+ this.allSessions.add(session);
313
+ },
314
+ untrack: (session) => {
315
+ this.allSessions.delete(session);
316
+ },
317
+ onDirectory: (entries) => {
318
+ mergeAndAnnounceReachable(this.knownDevices, entries, this.events);
319
+ },
320
+ handleRoomRequest: this.roomRouter.handleRelayedRequest,
321
+ isTrusted: (deviceHex) => this.gatewayTrust.isTrusted(deviceHex),
322
+ admitEntries: directoryAdmission({ gatewayTrust, verifyMembership }),
323
+ });
324
+ this.pendingConnectionTimeoutMs = pendingConnectionTimeoutMs;
325
+ this.getCurrentPresence = getCurrentPresence;
326
+ this.getHostedRooms = getHostedRooms;
327
+ this.getSelfAgentAdvert = getSelfAgentAdvert;
328
+ this.gossipInterval = startGossipInterval({
329
+ ...this.gossipOptions(),
330
+ intervalMs: presenceReadvertiseIntervalMs,
331
+ });
332
+ }
333
+
334
+ /** Everything readvertiseGossip needs, read live from this transport's own fields, so the periodic tick and an on-demand readvertise (a new hub session's first advert) send the same thing. */
335
+ private gossipOptions(): Parameters<typeof readvertiseGossip>[0] {
336
+ return {
337
+ allSessions: this.allSessions,
338
+ hub: this.hub,
339
+ hasAnyTrustedGateway: () => this.gatewayTrust.hasAny(),
340
+ onError: this.events.onError,
341
+ getCurrentPresence: this.getCurrentPresence,
342
+ getHostedRooms: this.getHostedRooms,
343
+ getSelfAgentAdvert: this.getSelfAgentAdvert,
344
+ getCcPeerVersion: () => this.getCcPeerVersion?.(),
345
+ getRelayOffer: () => this.relay.offer(),
346
+ };
347
+ }
348
+
349
+ /** Sends one data-have or data-request frame directly to an already-connected peer: the mechanical send primitive a future catch-up policy calls once it decides to (see DataFrameRouter's dataStorage doc comment). Throws if this side has never received any frame from that peer yet (there is no connection to send on), matching sendManageRequest's own "no reachable session" failure mode for an unknown peer. */
350
+ async sendDataFrame(
351
+ peerDeviceHex: string,
352
+ frame: Readonly<DataHaveFrame> | Readonly<DataRequestFrame>,
353
+ ): Promise<void> {
354
+ await this.dataFrames.send(peerDeviceHex, frame);
355
+ }
356
+
357
+ /** Routes one core/data frame: see data-frame-routing.ts's own DataFrameRouter for the actual dispatch, kept out of this already-large file. */
358
+ private async handleDataFrame(
359
+ connection: Readonly<Connection>,
360
+ frame: Frame,
361
+ ): Promise<void> {
362
+ await this.dataFrames.route(connection, frame);
363
+ }
364
+
365
+ // -- Public getters --
366
+
367
+ get dataPort(): number {
368
+ return this._dataPort;
369
+ }
370
+
371
+ get isCoordinator(): boolean {
372
+ return this._isCoordinator;
373
+ }
374
+
375
+ get hasCoordinatorConnection(): boolean {
376
+ return this.coordinatorSession !== undefined;
377
+ }
378
+
379
+ get coordinatorPeerId(): string | undefined {
380
+ return this.coordinatorDeviceHex;
381
+ }
382
+
383
+ // -----------------------------------------------------------------------
384
+ // Connection acceptance -- shared by every listener (coordinator or data)
385
+ // -----------------------------------------------------------------------
386
+
387
+ private async handleAcceptedConnection(
388
+ connection: Readonly<Connection>,
389
+ accept: Readonly<AcceptOptions>,
390
+ ): Promise<void> {
391
+ const { policy, fireOnPeerConnected, requiresApproval, machineLocal } =
392
+ accept;
393
+ const peerDeviceId = connection.peerDeviceId;
394
+ if (peerDeviceId === undefined) {
395
+ // No certificate presented at all -- nothing to authenticate this peer against.
396
+ await connection.close();
397
+ return;
398
+ }
399
+ const deviceIdHex = deviceIdToHex(peerDeviceId);
400
+ const session = await this.openSession(connection);
401
+ if (session === undefined) return;
402
+ // ConnectionHandle.policy is `?: ListenerPolicy`, not `?: ListenerPolicy | undefined` -- under exactOptionalPropertyTypes these are genuinely different types, so the key must be entirely absent rather than present-with-undefined-value when this listener has no policy of its own.
403
+ const handle: ConnectionHandle = {
404
+ id: deviceIdHex,
405
+ ...(policy !== undefined ? { policy } : {}),
406
+ };
407
+
408
+ if (requiresApproval) {
409
+ // A listener a stranger can dial cold (the coordinator port, or any addListener-created listener) must not hand out full routing on the strength of a TLS handshake alone -- that only proves which key the far side holds, never that a human has approved them as a mesh member. Quarantine every request from this session until it's either an `introduce` (the pre-existing, ungated coordinator-handoff path, unchanged from before this substrate swap) or an approved `connect_request`.
410
+ this.allSessions.add(session);
411
+ this.consumeQuarantined(session, handle, machineLocal);
412
+ this.watchForDisconnect(session, handle, deviceIdHex);
413
+ return;
414
+ }
415
+
416
+ this.trackSession(deviceIdHex, session);
417
+ if (fireOnPeerConnected) {
418
+ const info: PeerInfo = {
419
+ id: deviceIdHex,
420
+ port: 0,
421
+ startedAt: new Date().toISOString(),
422
+ };
423
+ this.events.onPeerConnected(handle, info);
424
+ }
425
+
426
+ this.consumeIncoming(session, handle, machineLocal);
427
+ this.watchForDisconnect(session, handle, deviceIdHex);
428
+ }
429
+
430
+ /** Reads a not-yet-trusted session's requests until it's promoted (an `introduce`, handled and trusted immediately, matching the pre-existing coordinator-handoff trust boundary) or a `connect_request` arrives, at which point this same loop iteration blocks on the human decision Promise stored in pendingConnections -- resumed in place by acceptConnection/rejectConnection (or by watchForDisconnect, if the requester disconnects first) -- rather than ever stopping and later re-entering the session's incomingManageRequests from a second call, which would require assuming a fresh access yields a distinct, independently-advancing iterator rather than resuming the one already in progress. Anything else arriving before introduce/connect_request is a protocol violation from an unapproved peer and is refused and closed rather than routed. */
431
+ private consumeQuarantined(
432
+ session: AcceptedMeshSession,
433
+ handle: Readonly<ConnectionHandle>,
434
+ machineLocal: boolean,
435
+ ): void {
436
+ // Watched from the start, so any claim this session gossips before it introduces itself is read and dropped rather than held back and replayed once it is trusted.
437
+ this.electionSessions.watch(session, handle);
438
+ void (async () => {
439
+ let approved = false;
440
+ for await (const request of session.incomingManageRequests) {
441
+ if (this.shutDown) break;
442
+ if (approved) {
443
+ await this.roomRouter.handleLocalRequest(request, handle);
444
+ continue;
445
+ }
446
+ const message = extractMessage(request.command);
447
+ if (message === undefined) {
448
+ await request.respond({ result: "ok" }).catch(() => undefined);
449
+ continue;
450
+ }
451
+ if (message.method === "introduce") {
452
+ this.trackSession(handle.id, session);
453
+ // Trusted from here on, so its coordinator claims count from here on, and only from here on.
454
+ if (machineLocal) this.electionSessions.enrol(session, handle);
455
+ this.events.onIntroduction(handle, {
456
+ peerId: handle.id,
457
+ dataPort: message.dataPort,
458
+ });
459
+ await request.respond({ result: "ok" }).catch(() => undefined);
460
+ approved = true;
461
+ continue;
462
+ }
463
+ if (message.method === "connect_request") {
464
+ // Held open deliberately (see this file's own header comment). Answered later by acceptConnection/rejectConnection, or by expirePendingConnection if neither happens within pendingConnectionTimeoutMs.
465
+ const decision = await new Promise<ConnectionDecision>((resolve) => {
466
+ this.pendingConnections.set(handle.id, {
467
+ respond: request.respond,
468
+ dataPort: message.dataPort,
469
+ name: message.name,
470
+ fingerprint: message.fingerprint,
471
+ policy: handle.policy,
472
+ session,
473
+ resolve,
474
+ // unref()'d so a stray pending connection (this timer failing to be cleared through some path not yet covered) can never by itself keep the process alive for up to pendingConnectionTimeoutMs after everything else is done -- confirmed necessary directly: shutdown() originally cleared every pendingConnections entry without clearing its timer, and the whole test process hung for the full default timeout before exiting.
475
+ timeoutHandle: setTimeout(() => {
476
+ expirePendingConnection(this.pendingConnections, handle.id);
477
+ }, this.pendingConnectionTimeoutMs).unref(),
478
+ });
479
+ this.events.onConnectionRequest(handle, {
480
+ peerId: handle.id,
481
+ dataPort: message.dataPort,
482
+ name: message.name,
483
+ fingerprint: message.fingerprint,
484
+ });
485
+ });
486
+ if (decision === "reject") {
487
+ // Rejection can arrive via rejectConnection (session still open) or via shutdown/disconnect (session already closing) -- catch rather than assume which.
488
+ await session.close().catch(() => undefined);
489
+ return;
490
+ }
491
+ // acceptConnection has already called trackSession synchronously, before firing onIntroduction -- nothing left to do here beyond trusting subsequent requests on this same session.
492
+ approved = true;
493
+ continue;
494
+ }
495
+ // Any other message before introduce/connect_request is a protocol violation from an unapproved peer -- refuse and terminate rather than route it.
496
+ await request
497
+ .respond({ result: "error", code: "not_approved" })
498
+ .catch(() => undefined);
499
+ await session.close().catch(() => undefined);
500
+ return;
501
+ }
502
+ })();
503
+ }
504
+
505
+ /** Opens a mesh session over an already-established connection, wired to this transport's own frame dispatch and advertised addresses. Every session this transport owns is built exactly this way, whether the connection was accepted by a listener or dialled at a peer, at the coordinator, or at a remote gateway, so the wiring lives here rather than being restated at each call site. Resolves undefined, with the session closed, when shutdown() has begun by the time the session is open, whether it began before the connection arrived or while the session was opening: shutdown() closes only the sessions it can see, so one opened after it started would stay open, and its peer would never learn that this side had gone (agent-comms#372). */
506
+ private async openSession(
507
+ connection: Readonly<Connection>,
508
+ ): Promise<AcceptedMeshSession | undefined> {
509
+ const identity = await this.identityReady;
510
+ const session = await acceptMeshSession(connection, identity, [DOMAIN], {
511
+ onFrame: async (conn, frame) => {
512
+ await this.handleDataFrame(conn, frame);
513
+ },
514
+ addresses: this.advertisedAddresses,
515
+ });
516
+ if (this.isShuttingDown()) {
517
+ await session.close();
518
+ return undefined;
519
+ }
520
+ return session;
521
+ }
522
+
523
+ /** Delegates the whole post-approval drain loop to roomRouter: this transport no longer decodes or routes a MeshMessage itself, only hands the session off. Also starts this session's own independent revocationAnnouncements drain, since a gossiped revocation is not a manage-request and has no verb for roomRouter to dispatch, and its coordinator-frame watch for the same reason, enrolling the session in the election only when machineLocal says it reaches this machine. */
524
+ private consumeIncoming(
525
+ session: AcceptedMeshSession,
526
+ handle: Readonly<ConnectionHandle>,
527
+ machineLocal: boolean,
528
+ ): void {
529
+ this.roomRouter.drainSession(session, handle);
530
+ this.drainRevocationAnnouncements(session);
531
+ this.electionSessions.watch(session, handle);
532
+ if (machineLocal) this.electionSessions.enrol(session, handle);
533
+ }
534
+
535
+ /** Reads every revocation-entry this session's peer announces, for as long as the session lives, handing each one to MeshStore via onRevocationAnnounce -- one call per entry, matching revocationAnnouncements' own per-entry flattening of a revocation-announce frame's entries array. */
536
+ private drainRevocationAnnouncements(session: AcceptedMeshSession): void {
537
+ void (async () => {
538
+ for await (const entry of session.revocationAnnouncements) {
539
+ this.events.onRevocationAnnounce(entry);
540
+ }
541
+ })();
542
+ }
543
+
544
+ private watchForDisconnect(
545
+ session: AcceptedMeshSession,
546
+ handle: Readonly<ConnectionHandle>,
547
+ deviceIdHex: string,
548
+ ): void {
549
+ void (async () => {
550
+ // The presence this session last reported. Every session event carries the whole directory, including events this side causes by sending a frame and the final closed event, so the peer's advert repeats long after the peer sent it. Only a change is news: re-reporting a stale advert would mark a departed peer's agent running again after the coordinator has set it offline, and nothing would ever correct that.
551
+ let reportedPresence: AgentStatus | undefined;
552
+ for await (const event of session.events) {
553
+ this.knownDevices.mergeLocal(event.directory);
554
+ const presence = findPresenceAdvert(deviceIdHex, event.directory);
555
+ if (presence !== undefined && presence !== reportedPresence) {
556
+ reportedPresence = presence;
557
+ this.events.onPresenceAdvert(handle, presence);
558
+ }
559
+ if (event.state.status === "closed") {
560
+ const wasTracked = this.peerSessions.get(deviceIdHex) === session;
561
+ if (wasTracked) this.peerSessions.delete(deviceIdHex);
562
+ this.allSessions.delete(session);
563
+ this.electionSessions.delete(session);
564
+ // Dropping the closed coordinator session is what makes hasCoordinatorConnection answer honestly once the coordinator process is gone; coordinatorDeviceHex deliberately survives, so the onPeerDisconnected fired just below can still be recognised as the coordinator's own departure rather than an ordinary peer's.
565
+ if (this.coordinatorSession === session)
566
+ this.coordinatorSession = undefined;
567
+ // A requester disconnecting before a human decides must unblock consumeQuarantined's own still-suspended loop iteration -- otherwise that promise, and the closure awaiting it, never settle.
568
+ const pending = this.pendingConnections.get(deviceIdHex);
569
+ if (pending !== undefined) {
570
+ clearTimeout(pending.timeoutHandle);
571
+ pending.resolve("reject");
572
+ this.pendingConnections.delete(deviceIdHex);
573
+ }
574
+ // A no-op when this session was never a connectToPeer dial (e.g. the coordinator-client or an accepted connection) -- Set.delete on an absent key is always safe.
575
+ this.dataDials.delete(deviceIdHex);
576
+ if (wasTracked && !this.shutDown) {
577
+ this.events.onPeerDisconnected(handle);
578
+ }
579
+ return;
580
+ }
581
+ }
582
+ })();
583
+ }
584
+
585
+ // -----------------------------------------------------------------------
586
+ // MeshTransport -- Data server
587
+ // -----------------------------------------------------------------------
588
+
589
+ async startDataServer(): Promise<void> {
590
+ this.dataListener = await this.wireTransport.listen(
591
+ `${COORDINATOR_HOST}:0`,
592
+ (connection) => {
593
+ void this.handleAcceptedConnection(connection, DATA_SERVER_ACCEPT);
594
+ },
595
+ );
596
+ this._dataPort = listenerPort(this.dataListener);
597
+ }
598
+
599
+ // -----------------------------------------------------------------------
600
+ // MeshTransport -- Coordinator connection (client side)
601
+ // -----------------------------------------------------------------------
602
+
603
+ async connectToCoordinator(
604
+ host: string,
605
+ port: number,
606
+ peerId: string,
607
+ dataPort: number,
608
+ ): Promise<void> {
609
+ const connection = await this.wireTransport.connect(
610
+ `${host}:${String(port)}`,
611
+ );
612
+ const session = await this.openSession(connection);
613
+ if (session === undefined) {
614
+ throw new Error("connectToCoordinator: the transport has shut down");
615
+ }
616
+ this.coordinatorSession = session;
617
+ // Fire-and-forget, matching the previous transport's own contract: this resolves once the introduction is sent, not once a response arrives; peer_list/peer_joined/become_coordinator arrive asynchronously via the normal dispatch path below, independent of this call's own promise. Written before the session is enrolled below, because enrolling announces the elected coordinator over it and the port holder drops any claim that reaches it ahead of the introduce.
618
+ void this.sendFrame(
619
+ session,
620
+ buildCommand({ method: "introduce", peerId, dataPort }),
621
+ ).catch(() => undefined);
622
+ const coordinatorDeviceId = connection.peerDeviceId;
623
+ if (coordinatorDeviceId !== undefined) {
624
+ const handle: ConnectionHandle = {
625
+ id: deviceIdToHex(coordinatorDeviceId),
626
+ };
627
+ this.coordinatorDeviceHex = handle.id;
628
+ this.trackSession(handle.id, session);
629
+ // Machine-local only when the address dialled was a loopback one: the dialler knows nothing else about where the far side is.
630
+ this.consumeIncoming(session, handle, isLoopbackAddress(host));
631
+ this.watchForDisconnect(session, handle, handle.id);
632
+ }
633
+ }
634
+
635
+ // -----------------------------------------------------------------------
636
+ // MeshTransport -- Become coordinator (server side)
637
+ // -----------------------------------------------------------------------
638
+
639
+ async becomeCoordinator(host: string, port: number): Promise<void> {
640
+ this.defaultListenerId = await registerDefaultListener({
641
+ wireTransport: this.wireTransport,
642
+ coordinatorListeners: this.coordinatorListeners,
643
+ host,
644
+ port,
645
+ onAccepted: (connection) => {
646
+ void this.handleAcceptedConnection(connection, DEFAULT_LISTENER_ACCEPT);
647
+ },
648
+ isShuttingDown: () => this.isShuttingDown(),
649
+ });
650
+ this._isCoordinator = true;
651
+ // This side now IS the coordinator, so it answers to no other one: leaving a departed coordinator's device-id here would keep coordinatorPeerId naming a peer whose disconnect has already been dealt with, and would leave a stale device-id where the coordinator role is now this side's own.
652
+ this.coordinatorDeviceHex = undefined;
653
+ this.coordinatorSession = undefined;
654
+ }
655
+
656
+ // -----------------------------------------------------------------------
657
+ // MeshTransport -- Peer-to-peer data connections
658
+ // -----------------------------------------------------------------------
659
+
660
+ async connectToPeer(
661
+ peer: Readonly<PeerInfo>,
662
+ _ownPeerId: string,
663
+ ): Promise<void> {
664
+ if (this.shutDown || this.dataDials.has(peer.id)) return;
665
+ this.dataDials.add(peer.id);
666
+ const host = peer.host ?? COORDINATOR_HOST;
667
+ const connection = await this.wireTransport
668
+ .connect(`${host}:${String(peer.port)}`)
669
+ .catch((error: unknown) => {
670
+ this.events.onError?.(
671
+ new PeerLinkError("dial", peer.id, peer.port, error),
672
+ );
673
+ return undefined;
674
+ });
675
+ if (connection === undefined) {
676
+ this.dataDials.delete(peer.id);
677
+ return;
678
+ }
679
+ const peerDeviceId = connection.peerDeviceId;
680
+ if (peerDeviceId === undefined) {
681
+ this.dataDials.delete(peer.id);
682
+ await connection.close();
683
+ return;
684
+ }
685
+ const deviceIdHex = deviceIdToHex(peerDeviceId);
686
+ if (deviceIdHex !== peer.id) {
687
+ // The peer we reached does not hold the key peer.id claims to name -- refuse exactly like a mismatched fingerprint would have under the previous transport.
688
+ this.events.onError?.(
689
+ new Error(
690
+ `connectToPeer: peer at port ${String(peer.port)} authenticated as ${deviceIdHex}, expected ${peer.id}`,
691
+ ),
692
+ );
693
+ this.dataDials.delete(peer.id);
694
+ await connection.close();
695
+ return;
696
+ }
697
+ const session = await this.openSession(connection);
698
+ if (session === undefined) {
699
+ this.dataDials.delete(peer.id);
700
+ return;
701
+ }
702
+ this.trackSession(deviceIdHex, session);
703
+ const handle: ConnectionHandle = { id: deviceIdHex };
704
+ // Machine-local only when the address dialled was a loopback one. A peer list or a first-contact beacon heard over LAN broadcast can name any host, and a device there can serve a session under the very device-id it announced, so authenticating the far side proves who it is, never that it is on this machine.
705
+ this.consumeIncoming(session, handle, isLoopbackAddress(host));
706
+ this.watchForDisconnect(session, handle, deviceIdHex);
707
+ }
708
+
709
+ // -----------------------------------------------------------------------
710
+ // MeshTransport -- Send / broadcast
711
+ // -----------------------------------------------------------------------
712
+
713
+ async send(
714
+ handle: Readonly<ConnectionHandle>,
715
+ message: MeshMessage,
716
+ ): Promise<void> {
717
+ const session = this.peerSessions.get(handle.id);
718
+ if (session === undefined) return;
719
+ await this.sendFrame(session, buildCommand(message)).catch(
720
+ (error: unknown) => {
721
+ this.events.onError?.(
722
+ new PeerLinkError("send", handle.id, undefined, error),
723
+ );
724
+ },
725
+ );
726
+ }
727
+
728
+ /** Sends one opaque MeshMessage frame over a session under the ordinary network deadline. The deadline is what makes it safe to await: wire-mesh-core waits forever for a manage-response when given none, so a frame sent to a peer whose socket has already died but whose close this side has not yet observed would never settle, and MeshStore.shutdown() awaits exactly such a broadcast on its way out. */
729
+ private async sendFrame(
730
+ session: AcceptedMeshSession,
731
+ command: ManageCommand,
732
+ ): Promise<ManageOutcome> {
733
+ return session.sendManageRequest(
734
+ command,
735
+ FRAME_SCOPE,
736
+ undefined,
737
+ undefined,
738
+ ROOM_REQUEST_TIMEOUT_MS,
739
+ );
740
+ }
741
+
742
+ async broadcast(message: MeshMessage): Promise<void> {
743
+ const command = buildCommand(message);
744
+ await Promise.all(
745
+ [...this.peerSessions.values()].map(async (session) =>
746
+ this.sendFrame(session, command).catch(() => undefined),
747
+ ),
748
+ );
749
+ }
750
+
751
+ async broadcastRevocation(
752
+ entries: readonly RevocationEntry[],
753
+ ): Promise<void> {
754
+ await Promise.all(
755
+ [...this.peerSessions.values()].map(async (session) =>
756
+ session.sendRevocationAnnounce(entries).catch(() => undefined),
757
+ ),
758
+ );
759
+ }
760
+
761
+ async broadcastCoordinatorClaim(
762
+ frame: Readonly<CoordinatorFrame>,
763
+ ): Promise<void> {
764
+ await this.electionSessions.broadcast(frame);
765
+ }
766
+
767
+ async sendCoordinatorClaim(
768
+ handle: Readonly<ConnectionHandle>,
769
+ frame: Readonly<CoordinatorFrame>,
770
+ ): Promise<void> {
771
+ await this.electionSessions.sendTo(handle.id, frame);
772
+ }
773
+
774
+ electionPeerIds(): ReadonlySet<string> {
775
+ return this.electionSessions.peerIds();
776
+ }
777
+
778
+ async sendRoomRequest(
779
+ memberId: string,
780
+ command: ManageCommand,
781
+ scope: Readonly<CapabilityScope>,
782
+ token?: CapabilityToken,
783
+ ): Promise<ManageOutcome> {
784
+ const session = this.peerSessions.get(memberId);
785
+ if (session !== undefined) {
786
+ return session.sendManageRequest(
787
+ command,
788
+ scope,
789
+ undefined,
790
+ token,
791
+ manageRequestTimeoutMs(command, this.roomJoinApprovalTimeoutMs),
792
+ );
793
+ }
794
+ // no_route, never unauthorized: this side has nowhere to send the request, which is a fact about this side's own routing rather than an answer from memberId, and a caller must be able to tell the two apart. A device with no session that this side is willing to relay for may become reachable later, once it connects or once an operator trusts it, so a request for it is worth holding; a request the recipient itself refused never is.
795
+ if (!this.gatewayTrust.isReachable(memberId)) {
796
+ return { result: "error", code: "no_route" };
797
+ }
798
+ return routeRoomRequestViaHub({
799
+ hub: this.hub,
800
+ memberId,
801
+ command,
802
+ scope,
803
+ token,
804
+ });
805
+ }
806
+
807
+ /** Asks deviceId for its own, currently-running wire-mesh-core version, live, right now (agent-comms#198's own query_version action) -- rides sendRoomRequest's own routing (a direct peerSessions session when one exists, else a trusted hub relay, else "unauthorized") against wire-mesh-core's deliberately-ungated core/version domain (spec/version.cddl): "version:get" on the outer manage-command only exists to satisfy manage-command.verb's own capability-verb grammar, and the scope below (kind "node") is arbitrary -- the receiving side never actually inspects either for this verb. */
808
+ async queryVersion(deviceId: string): Promise<ManageOutcome> {
809
+ return this.sendRoomRequest(
810
+ deviceId,
811
+ { verb: "version:get", params: { verb: "version.get" } },
812
+ { kind: "node" },
813
+ );
814
+ }
815
+
816
+ // -----------------------------------------------------------------------
817
+ // MeshTransport -- Connection approval (connectToRemote flow)
818
+ // -----------------------------------------------------------------------
819
+
820
+ async connectToRemote(
821
+ options: Readonly<ConnectToRemoteOptions>,
822
+ ): Promise<void> {
823
+ const { host, port, peerId, dataPort, name, fingerprint } = options;
824
+ const connection = await dialRemote(this.wireTransport, host, port);
825
+ const session = await this.openSession(connection);
826
+ if (session === undefined) {
827
+ throw new Error("connectToRemote: the transport has shut down");
828
+ }
829
+ const outcome = await session.sendManageRequest(
830
+ buildCommand({
831
+ method: "connect_request",
832
+ peerId,
833
+ dataPort,
834
+ name,
835
+ fingerprint,
836
+ }),
837
+ FRAME_SCOPE,
838
+ );
839
+ if (outcome.result === "error") {
840
+ await session.close();
841
+ throw new Error(outcome.message ?? outcome.code);
842
+ }
843
+ const coordinatorDeviceId = connection.peerDeviceId;
844
+ if (coordinatorDeviceId !== undefined) {
845
+ const handle: ConnectionHandle = {
846
+ id: deviceIdToHex(coordinatorDeviceId),
847
+ };
848
+ this.trackSession(handle.id, session);
849
+ this.consumeIncoming(session, handle, false);
850
+ this.watchForDisconnect(session, handle, handle.id);
851
+ }
852
+ }
853
+
854
+ async acceptConnection(handle: Readonly<ConnectionHandle>): Promise<void> {
855
+ const pending = this.pendingConnections.get(handle.id);
856
+ if (pending === undefined) {
857
+ throw new Error(`No pending connection for handle ${handle.id}`);
858
+ }
859
+ clearTimeout(pending.timeoutHandle);
860
+ this.pendingConnections.delete(handle.id);
861
+ await pending.respond({ result: "ok" });
862
+ // Must happen before onIntroduction fires below: mesh-store's own handleIntroduction sends a reply on this exact handle synchronously as part of handling that event, which needs peerSessions already populated -- waiting for consumeQuarantined's own suspended loop to resume (via resolve() below) would race, since that only happens on a later microtask tick.
863
+ this.trackSession(handle.id, pending.session);
864
+ const acceptedHandle: ConnectionHandle = {
865
+ id: handle.id,
866
+ ...(pending.policy !== undefined ? { policy: pending.policy } : {}),
867
+ };
868
+ this.events.onIntroduction(acceptedHandle, {
869
+ peerId: handle.id,
870
+ dataPort: pending.dataPort,
871
+ });
872
+ // Resumes consumeQuarantined's own still-blocked loop iteration so it starts trusting subsequent requests on this same session.
873
+ pending.resolve("accept");
874
+ }
875
+
876
+ async rejectConnection(
877
+ handle: Readonly<ConnectionHandle>,
878
+ reason: string,
879
+ ): Promise<void> {
880
+ const pending = this.pendingConnections.get(handle.id);
881
+ if (pending === undefined) {
882
+ throw new Error(`No pending connection for handle ${handle.id}`);
883
+ }
884
+ clearTimeout(pending.timeoutHandle);
885
+ this.pendingConnections.delete(handle.id);
886
+ await pending.respond({
887
+ result: "error",
888
+ code: "rejected",
889
+ message: reason,
890
+ });
891
+ // Resumes consumeQuarantined's blocked loop iteration, which closes the session itself -- it was never tracked anywhere else to close it from here.
892
+ pending.resolve("reject");
893
+ }
894
+
895
+ // -----------------------------------------------------------------------
896
+ // MeshTransport -- Listener management
897
+ // -----------------------------------------------------------------------
898
+
899
+ async addListener(
900
+ host: string,
901
+ port: number,
902
+ policy: ListenerPolicy,
903
+ ): Promise<string> {
904
+ return registerListener({
905
+ wireTransport: this.wireTransport,
906
+ coordinatorListeners: this.coordinatorListeners,
907
+ host,
908
+ port,
909
+ policy,
910
+ onAccepted: (connection) => {
911
+ void this.handleAcceptedConnection(
912
+ connection,
913
+ operatorListenerAccept(policy),
914
+ );
915
+ },
916
+ });
917
+ }
918
+
919
+ async removeListener(id: string): Promise<void> {
920
+ await unregisterListener(
921
+ this.coordinatorListeners,
922
+ this.defaultListenerId,
923
+ id,
924
+ );
925
+ }
926
+
927
+ listListeners(): ListenerInfo[] {
928
+ return listTrackedListeners(this.coordinatorListeners);
929
+ }
930
+
931
+ /** This side's own directly-reachable "host:port" candidates (wire-mesh#38), passed into every acceptMeshSession call's own self-advert -- see advertisedListenerAddresses' own doc for what's included/excluded and why. */
932
+ private get advertisedAddresses(): string[] {
933
+ return advertisedListenerAddresses(this.coordinatorListeners);
934
+ }
935
+
936
+ // -----------------------------------------------------------------------
937
+ // MeshTransport -- Hub (agent-comms#154, per-store since agent-comms#293)
938
+ // -----------------------------------------------------------------------
939
+
940
+ joinHub(url: string, shouldConnect: () => boolean): void {
941
+ this.relay.joinHub(this.hub, url, shouldConnect);
942
+ }
943
+
944
+ reconsiderHub(): void {
945
+ this.relay.reconsiderHub();
946
+ }
947
+
948
+ setRelayEligible(eligible: boolean): void {
949
+ this.relay.setEligible(eligible);
950
+ }
951
+
952
+ // -----------------------------------------------------------------------
953
+ // MeshTransport -- Shutdown / unref
954
+ // -----------------------------------------------------------------------
955
+
956
+ async shutdown(): Promise<void> {
957
+ this.shutDown = true;
958
+ // A shut-down transport holds no listener, so it is no longer the coordinator -- leaving the flag set would have a second shutdown pass try to hand the role on, and would have isCoordinator lie to anything that outlives the transport.
959
+ this._isCoordinator = false;
960
+
961
+ // Closing starts here, ahead of every other cleanup step below (agent-comms#302), but the returned promises are deliberately not awaited until the end of this method. A graceful coordinator handover sends its become_coordinator message before this shutdown() call even starts (PeerLifecycle.sendCoordinatorHandover runs first in MeshStore.shutdown()), so the successor can already be racing to rebind this exact host:port through a bounded number of retries (bind-retry.ts) that assume the port frees up promptly. Node's net.Server.close() (which tls.Server inherits unchanged) releases the OS-level bound port as soon as it's *called* -- confirmed directly against the Node version this runs on, not assumed from the API docs -- but the promise it returns resolves only once every connection already accepted through that listener has also ended, exactly the delay #302 was caused by when close() itself wasn't even invoked until after that same cleanup ran. Firing close() now and awaiting it later, once sessions are already being closed anyway, gets both: the port frees at once for the successor, and shutdown() still doesn't return until the listeners are fully torn down. Nothing new should be accepted in the meantime regardless of exactly when the listener finishes closing, since shutDown is already true and handleAcceptedConnection's own guard closes anything that slips in.
962
+ const dataListenerClosing = this.dataListener?.close();
963
+ const coordinatorListenersClosing = [
964
+ ...this.coordinatorListeners.values(),
965
+ ].map(async (tracked) => tracked.listener.close());
966
+
967
+ await this.relay.stop();
968
+ if (this.gossipInterval !== undefined) {
969
+ clearInterval(this.gossipInterval);
970
+ this.gossipInterval = undefined;
971
+ }
972
+ this.dataFrames.stop();
973
+ this.dataDials.clear();
974
+
975
+ for (const pending of this.pendingConnections.values()) {
976
+ clearTimeout(pending.timeoutHandle);
977
+ await pending
978
+ .respond({ result: "error", code: "shutting_down" })
979
+ .catch(() => undefined);
980
+ // Unblocks consumeQuarantined's own suspended loop iteration for this session -- otherwise it never settles, even though the session itself is about to be closed below via allSessions.
981
+ pending.resolve("reject");
982
+ }
983
+ this.pendingConnections.clear();
984
+
985
+ // Every live session, whichever of possibly several to the same peer -- peerSessions alone would only close the last one registered under a shared key, leaving any other (e.g. the coordinator-client session to a peer this side also has a data connection to) still open.
986
+ for (const session of this.allSessions) {
987
+ await session.close().catch(() => undefined);
988
+ }
989
+ this.allSessions.clear();
990
+ this.electionSessions.clear();
991
+ this.peerSessions.clear();
992
+ this.coordinatorSession = undefined;
993
+
994
+ // The two closes started right at the top of this method have had this entire method's own cleanup time to finish now that every session they were waiting on is closed -- awaiting them here should very rarely find either still pending, but shutdown() still guarantees both listeners are fully torn down before it returns.
995
+ await dataListenerClosing;
996
+ this.dataListener = undefined;
997
+ await Promise.all(coordinatorListenersClosing);
998
+ this.coordinatorListeners.clear();
999
+ }
1000
+
1001
+ unref(): void {
1002
+ this.dataListener?.unref?.();
1003
+ for (const tracked of this.coordinatorListeners.values()) {
1004
+ tracked.listener.unref?.();
1005
+ }
1006
+ }
1007
+ }