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,354 @@
1
+ /**
2
+ * Protocol handshake — version negotiation for the mesh wire format, fixing #31: a mixed fleet of old and new peers negotiates down to what both actually support (or refuses loudly) instead of one side silently misinterpreting the other's state sync.
3
+ *
4
+ * The frame is wire-mesh's handshake-frame (spec/handshake.cddl in ExaDev/wire-mesh), CBOR-encoded, negotiated by wire-mesh-core's `negotiate()` — the same mechanism every wire-mesh consumer speaks. The `version` field carries agent-comms' own wire-format version (not wire-mesh's protocol version): version 1 is the current format, the one with entity revision fields (#29) and deliveryQueues (#30). A peer that never sends a handshake frame is a legacy peer (version 0, unversioned) and the connection proceeds exactly as before — mixed-fleet tolerance during rollout, the scenario #31 describes.
5
+ *
6
+ * Wire order: a client sends its handshake frame as the very first bytes on a connection and does not wait — the rest of its traffic is ordinary newline-delimited JSON. A server sends its frame only in reply to a received one, so legacy clients never see binary bytes at all. The one unavoidable cross-build artefact is a legacy server receiving a new client's CBOR frame into its line buffer, where it lands without a newline and is flushed as a single malformed (skipped) line when the first JSON message arrives — the pre-existing malformed-line behaviour.
7
+ */
8
+
9
+ import {
10
+ decodeSequence,
11
+ encode as cborEncode,
12
+ cdeDecodeOptions,
13
+ cdeEncodeOptions,
14
+ } from "cbor2";
15
+ import {
16
+ negotiate,
17
+ type NegotiationResult,
18
+ } from "wire-mesh-core/domain/handshake";
19
+
20
+ /** agent-comms' own wire-format version. 1 = the current format (entity revision fields, deliveryQueues). */
21
+ export const MESH_PROTOCOL_VERSION = 1;
22
+
23
+ /**
24
+ * The capability domain this mesh negotiates under — a wire-mesh namespaced-domain-id (registrant-owned, no allocator): ExaDev's agent-comms mesh semantics. Peers that do not share it are not this protocol.
25
+ */
26
+ export const AGENT_COMMS_DOMAIN = "dev.exadev.agent-comms/mesh";
27
+
28
+ /** Handshake frames are tiny; anything larger than this is not a frame, it is garbage. */
29
+ const MAX_HANDSHAKE_BYTES = 1024;
30
+
31
+ /** CBOR map head first byte, inclusive. */
32
+ const CBOR_MAP_HEAD_MIN = 0xa0;
33
+ /** CBOR map head last byte, inclusive. */
34
+ const CBOR_MAP_HEAD_MAX = 0xbf;
35
+
36
+ /** CBOR map head byte range (0xa0-0xbf); JSON always starts with the byte 0x7b. */
37
+ function isCborMapHead(byte: number): boolean {
38
+ return byte >= CBOR_MAP_HEAD_MIN && byte <= CBOR_MAP_HEAD_MAX;
39
+ }
40
+
41
+ const JSON_OBJECT_START = 0x7b; // '{'
42
+ const HEX_RADIX = 16;
43
+
44
+ interface HandshakeShape {
45
+ type: "handshake";
46
+ version: number;
47
+ domains: string[];
48
+ }
49
+
50
+ /**
51
+ * Narrow structural check for a received handshake frame.
52
+ *
53
+ * Deliberately NOT the generated handshakeFrameSchema: its domain union's regexp branches are emitted double-escaped by cddl.js (ExaDev/cddl.js#10), so schema-validating any namespaced domain — including ours — rejects valid frames until that fix lands. This guard checks exactly the shape `negotiate()` consumes.
54
+ */
55
+ function isHandshakeShape(value: unknown): value is HandshakeShape {
56
+ if (typeof value !== "object" || value === null) return false;
57
+ if (!("type" in value) || value.type !== "handshake") return false;
58
+ if (!("version" in value) || typeof value.version !== "number") return false;
59
+ if (!("domains" in value) || !Array.isArray(value.domains)) return false;
60
+ return value.domains.every((d) => typeof d === "string");
61
+ }
62
+
63
+ function localFrame(): HandshakeShape {
64
+ return {
65
+ type: "handshake",
66
+ version: MESH_PROTOCOL_VERSION,
67
+ domains: [AGENT_COMMS_DOMAIN],
68
+ };
69
+ }
70
+
71
+ /** The handshake frame this build sends, CBOR-encoded (canonical), ready to write as a connection's first bytes. */
72
+ export function encodeHandshakeFrame(): Uint8Array {
73
+ return cborEncode(localFrame(), cdeEncodeOptions);
74
+ }
75
+
76
+ /** Negotiates this build's protocol against a received handshake frame — core's negotiation over agent-comms' versions. */
77
+ export function negotiateMeshProtocol(
78
+ remote: HandshakeShape,
79
+ ): NegotiationResult {
80
+ return negotiate(localFrame(), remote);
81
+ }
82
+
83
+ export type HandshakeOutcome =
84
+ | { kind: "pending" }
85
+ | { kind: "legacy"; rest: Buffer; reason: "json-first-byte" }
86
+ | { kind: "negotiated"; rest: Buffer; result: NegotiationResult }
87
+ | { kind: "refused"; reason: string };
88
+
89
+ /**
90
+ * Per-connection gate fed the incoming byte stream. Consumes the (optional) leading handshake frame and classifies the connection: negotiated (a version was agreed — for a server, the caller replies with `encodeHandshakeFrame()`), legacy (first byte was the JSON object-start byte — a pre-handshake peer, proceed exactly as before), or refused (a handshake we cannot speak: destroy the connection loudly rather than desync — the #31 enforcement point). After the first classification every subsequent feed passes the bytes through unchanged.
91
+ */
92
+ export class ConnectionHandshake {
93
+ private decided: "legacy" | "negotiated" | null = null;
94
+ private readonly pending: Buffer[] = [];
95
+ private pendingLength = 0;
96
+
97
+ constructor(private readonly role: "client" | "server") {}
98
+
99
+ feed(data: Buffer): HandshakeOutcome {
100
+ if (this.decided !== null) {
101
+ if (this.decided === "negotiated") {
102
+ return {
103
+ kind: "negotiated",
104
+ rest: data,
105
+ result: this.negotiatedResult(),
106
+ };
107
+ }
108
+ return { kind: "legacy", rest: data, reason: "json-first-byte" };
109
+ }
110
+ // The connection's very first byte only classifies legacy vs. CBOR. A later chunk (the second half of a split frame) starts mid-item, and its own leading byte is not a fresh frame head -- re-checking it against isCborMapHead on every chunk was the bug a reassembly test caught.
111
+ if (this.pending.length === 0) {
112
+ const first = data[0];
113
+ if (first === undefined) {
114
+ return { kind: "pending" };
115
+ }
116
+ if (first === JSON_OBJECT_START) {
117
+ this.decided = "legacy";
118
+ return { kind: "legacy", rest: data, reason: "json-first-byte" };
119
+ }
120
+ if (!isCborMapHead(first)) {
121
+ return {
122
+ kind: "refused",
123
+ reason: `unexpected first byte 0x${first.toString(HEX_RADIX)} — not a handshake frame or JSON message`,
124
+ };
125
+ }
126
+ }
127
+ // A CBOR item: accumulate until it decodes (frames are tiny; TCP may split them).
128
+ this.pending.push(data);
129
+ this.pendingLength += data.length;
130
+ if (this.pendingLength > MAX_HANDSHAKE_BYTES) {
131
+ return {
132
+ kind: "refused",
133
+ reason: `handshake frame exceeds ${String(MAX_HANDSHAKE_BYTES)} bytes`,
134
+ };
135
+ }
136
+ const joined = Buffer.concat(this.pending);
137
+ let value: unknown;
138
+ try {
139
+ // decodeSequence yields lazily: its first item resolves as soon as enough bytes exist for it, tolerating (rather than choking on) non-CBOR bytes that follow in the same buffer -- the ordinary case once a client's JSON traffic lands in the same TCP read as the frame. Plain decode() throws "Extra data in input" the instant anything trails the item, which would misclassify every such read as still-pending forever.
140
+ const item = decodeSequence(joined, cdeDecodeOptions).next();
141
+ if (item.done !== false) {
142
+ return { kind: "pending" };
143
+ }
144
+ value = item.value;
145
+ } catch {
146
+ return { kind: "pending" };
147
+ }
148
+ if (!isHandshakeShape(value)) {
149
+ return {
150
+ kind: "refused",
151
+ reason: "CBOR item on a new connection is not a handshake frame",
152
+ };
153
+ }
154
+ // The frame consumed only its own bytes; anything after it is the stream's JSON traffic.
155
+ const encoded = cborEncode(value, cdeEncodeOptions);
156
+ const rest = joined.subarray(encoded.length);
157
+ const result = negotiateMeshProtocol(value);
158
+ if (!result.ok) {
159
+ return {
160
+ kind: "refused",
161
+ reason: `handshake refused (${this.role}): peer protocol version ${String(value.version)}, no shared domain`,
162
+ };
163
+ }
164
+ this.decided = "negotiated";
165
+ this.lastResult = result;
166
+ return { kind: "negotiated", rest, result };
167
+ }
168
+
169
+ private lastResult: NegotiationResult | undefined;
170
+
171
+ private negotiatedResult(): NegotiationResult {
172
+ if (this.lastResult === undefined) {
173
+ throw new Error("negotiated connection has no negotiation result");
174
+ }
175
+ return this.lastResult;
176
+ }
177
+
178
+ /** True once this connection was classified (legacy or negotiated) — further feed() calls pass through. */
179
+ get settled(): boolean {
180
+ return this.decided !== null;
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Socket attachment (TCP/TLS) — one helper, every connection site
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** The slice of the Node socket surface the handshake needs — satisfied by net.Socket and tls.TLSSocket alike. */
189
+ export interface HandshakeSocket {
190
+ write: (data: Uint8Array | string) => unknown;
191
+ destroy: () => void;
192
+ on: (event: "data", listener: (data: Buffer) => void) => unknown;
193
+ }
194
+
195
+ /**
196
+ * Wires a connection's handshake: a client sends its frame immediately (and
197
+ * never waits — the rest of its traffic is JSON either way); a server sends
198
+ * its frame only in reply to a received one, so legacy clients never see
199
+ * binary bytes. Payload bytes after classification (and everything on a
200
+ * legacy connection) flow to `onPayload` unchanged. A refused handshake
201
+ * destroys the connection and reports the reason — the loud #31 refusal
202
+ * replacing silent desync.
203
+ */
204
+ export function attachSocketHandshake(
205
+ socket: Readonly<HandshakeSocket>,
206
+ role: "client" | "server",
207
+ onPayload: (data: Buffer) => void,
208
+ onError?: (error: Error) => void,
209
+ ): void {
210
+ const gate = new ConnectionHandshake(role);
211
+ if (role === "client") {
212
+ socket.write(encodeHandshakeFrame());
213
+ }
214
+ // A settled connection reports "negotiated" (or "legacy") on every subsequent feed, not just the classifying one -- feed() has no separate signal for "just decided" versus "already decided, passing through". Without this guard the server branch below wrote a fresh reply frame on every single data event for the rest of the connection's life, corrupting the peer's JSON-line buffer with stray CBOR bytes mid-stream.
215
+ let serverReplySent = false;
216
+ socket.on("data", (data: Buffer) => {
217
+ const outcome = gate.feed(data);
218
+ switch (outcome.kind) {
219
+ case "pending":
220
+ return;
221
+ case "legacy":
222
+ onPayload(outcome.rest);
223
+ return;
224
+ case "negotiated":
225
+ if (role === "server" && !serverReplySent) {
226
+ // Reply only once, on evidence the peer speaks the handshake — a legacy client must never receive binary bytes.
227
+ serverReplySent = true;
228
+ socket.write(encodeHandshakeFrame());
229
+ }
230
+ onPayload(outcome.rest);
231
+ return;
232
+ case "refused":
233
+ onError?.(new Error(outcome.reason));
234
+ socket.destroy();
235
+ return;
236
+ }
237
+ });
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // WebSocket attachment — binary first message is the handshake
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Gate for a WebSocket connection, where every message is already framed: a
246
+ * binary first message is the peer's handshake frame (reply in kind via
247
+ * `sendBinary` when serving), a text first message is a legacy peer's JSON.
248
+ * Binary messages after the first, or a non-handshake binary first message,
249
+ * are refused.
250
+ */
251
+ export class WsHandshakeGate {
252
+ private settled = false;
253
+
254
+ constructor(
255
+ private readonly role: "client" | "server",
256
+ private readonly sendBinary: (data: Uint8Array) => void,
257
+ ) {}
258
+
259
+ /**
260
+ * Classifies one incoming message. `isBinary` is the WS library's own frame-type flag (`ws`'s `message` event passes `(data, isBinary)`), not `typeof raw === "string"`: in Node, `ws` always delivers `data` as a Buffer regardless of whether the frame was sent as text or binary, so a `typeof` check can never see a text frame as a string here and would misclassify every legacy JSON message as an unexpected second handshake. `"payload"` means deliver it to the existing JSON message path (text only); `"consumed"` means it was the handshake and nothing downstream should see it; a throw is the refused case — the caller closes the socket.
261
+ */
262
+ feed(raw: unknown, isBinary: boolean): "payload" | "consumed" {
263
+ if (this.settled) {
264
+ if (!isBinary) return "payload";
265
+ throw new Error(
266
+ "binary message after connection start on a WebSocket mesh connection",
267
+ );
268
+ }
269
+ if (!isBinary) {
270
+ this.settled = true;
271
+ return "payload";
272
+ }
273
+ const bytes =
274
+ raw instanceof Buffer
275
+ ? raw
276
+ : raw instanceof ArrayBuffer
277
+ ? new Uint8Array(raw)
278
+ : ArrayBuffer.isView(raw)
279
+ ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
280
+ : undefined;
281
+ if (bytes === undefined) {
282
+ throw new Error("unsupported WebSocket message type");
283
+ }
284
+ const gate = new ConnectionHandshake(this.role);
285
+ const outcome = gate.feed(Buffer.from(bytes));
286
+ if (outcome.kind === "pending") {
287
+ throw new Error(
288
+ "handshake frame did not arrive as one WebSocket message",
289
+ );
290
+ }
291
+ if (outcome.kind === "refused") {
292
+ throw new Error(outcome.reason);
293
+ }
294
+ if (outcome.kind === "legacy") {
295
+ // A text-shaped payload cannot reach here (handled above); binary that
296
+ // is not a handshake frame is a refusal in ConnectionHandshake.
297
+ throw new Error(
298
+ "unexpected legacy classification for a binary WebSocket message",
299
+ );
300
+ }
301
+ if (this.role === "server") {
302
+ this.sendBinary(encodeHandshakeFrame());
303
+ }
304
+ if (outcome.rest.length > 0) {
305
+ throw new Error(
306
+ "handshake frame carried trailing bytes in a WebSocket message",
307
+ );
308
+ }
309
+ this.settled = true;
310
+ return "consumed";
311
+ }
312
+ }
313
+
314
+ /** The slice of the WebSocket surface the handshake needs. */
315
+ export interface HandshakeWs {
316
+ send: (data: string | Uint8Array) => unknown;
317
+ terminate: () => void;
318
+ // isBinary is the `ws` library's own frame-type flag (its `message` event always passes it as the second argument) -- the only reliable way to tell a text frame from a binary one, since `data` itself arrives as a Buffer in Node either way.
319
+ on: (
320
+ event: "message",
321
+ listener: (raw: unknown, isBinary: boolean) => void,
322
+ ) => unknown;
323
+ }
324
+
325
+ /**
326
+ * Wires a WebSocket connection's handshake: a client sends its frame as a
327
+ * binary message immediately (before any JSON); a server replies in kind only
328
+ * on receiving one, so legacy clients never see a binary message. Text
329
+ * messages flow to `onText` unchanged; a refused handshake terminates the
330
+ * connection and reports the reason.
331
+ */
332
+ export function attachWsHandshake(
333
+ ws: Readonly<HandshakeWs>,
334
+ role: "client" | "server",
335
+ onText: (raw: unknown) => void,
336
+ onError?: (error: Error) => void,
337
+ ): void {
338
+ const gate = new WsHandshakeGate(role, (data) => {
339
+ ws.send(data);
340
+ });
341
+ if (role === "client") {
342
+ ws.send(encodeHandshakeFrame());
343
+ }
344
+ ws.on("message", (raw: unknown, isBinary: boolean) => {
345
+ try {
346
+ if (gate.feed(raw, isBinary) === "payload") {
347
+ onText(raw);
348
+ }
349
+ } catch (error) {
350
+ onError?.(error instanceof Error ? error : new Error(String(error)));
351
+ ws.terminate();
352
+ }
353
+ });
354
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * hub-forwarding -- local-to-remote routing, split from wire-mesh-transport.ts under the repo's max-lines cap: falls a room-domain request back through the hub's own relay-connect/relay-data pairing when its target isn't a local peer session.
3
+ */
4
+
5
+ import type {
6
+ CapabilityScope,
7
+ CapabilityToken,
8
+ ManageCommand,
9
+ } from "wire-mesh-core/generated/protocol";
10
+ import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session";
11
+ import type { HubSession } from "./hub-session.js";
12
+
13
+ /** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only through the hub this store is connected to (agent-comms#155's local-to-remote leg). WireMeshTransport.sendRoomRequest itself gates memberId against the gateway trust boundary (agent-comms#156) before ever calling this, so by the time this runs memberId is already known-trusted -- this function stays focused on the hub-connectivity outcome alone. Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side holds no hub session, which is a store that has no agent yet, has a ghost agent, or whose machine trusts nobody. */
14
+ export async function routeRoomRequestViaHub(options: {
15
+ hub: Readonly<Pick<HubSession, "isConnected" | "sendRoomRequest">>;
16
+ memberId: string;
17
+ command: ManageCommand;
18
+ scope: Readonly<CapabilityScope>;
19
+ token?: CapabilityToken | undefined;
20
+ }): Promise<ManageOutcome> {
21
+ const { hub, memberId, command, scope, token } = options;
22
+ if (!hub.isConnected) return { result: "error", code: "not_connected" };
23
+ return hub.sendRoomRequest(memberId, command, scope, token);
24
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * HubLink keeps one store's own session on the relay hub alive while the store should have one: it dials in the background, waits for the session to end, and dials again after a delay that doubles up to a ceiling while the hub stays unreachable. Every store owns one (agent-comms#293), because reachability from another machine has to belong to the device itself and not to whichever store on the machine happens to hold the local coordinator role.
3
+ *
4
+ * The store decides through shouldConnect whether it wants a session at all, and a change of mind, made in this process or by another one editing the shared trust file, is picked up within a poll interval, or at once through reconsider(). A store with nothing to say to another machine (nothing trusted, no agent, or a ghost agent) holds no session, so it puts nothing on the hub, not even its device id.
5
+ *
6
+ * Which hub is dialled is chosen afresh before every dial (agent-comms#342): the hub is a set of relays, ranked by relay-selection.ts, with the configured public hub as the fallback. A dial that fails, or a session the relay drops, excludes that URL from selection for the redial ceiling (the cadence an unreachable hub is retried at anyway), so a relay that has gone falls through to the next choice instead of being redialled on every pass; and a session held over one relay is moved as soon as selection prefers another.
7
+ *
8
+ * The dial never blocks the caller: a bridge starting up offline must not wait out a connect timeout before it can serve its tools, and a hub that is down or restarting is an ordinary, recoverable state. Failures reach onError and the loop carries on.
9
+ */
10
+
11
+ import type { HubSession } from "./hub-session.js";
12
+
13
+ /** Delay before the first redial after a failed dial or a lost session. */
14
+ export const HUB_RECONNECT_INITIAL_DELAY_MS = 1000;
15
+
16
+ /** Ceiling the redial delay doubles up to while the hub stays unreachable, so an outage is retried about once a minute rather than backing off without bound. */
17
+ export const HUB_RECONNECT_MAX_DELAY_MS = 60_000;
18
+
19
+ /** How often a link re-evaluates shouldConnect while it has nothing to react to: a store that wants no session notices it now wants one, and a store holding one notices it no longer should, without anyone calling reconsider(). Bounds how long a trust change made by another process on the machine takes to show. */
20
+ export const HUB_LINK_POLL_INTERVAL_MS = 5000;
21
+
22
+ /** Factor the redial delay grows by after each failed dial. */
23
+ const RECONNECT_DELAY_GROWTH_FACTOR = 2;
24
+
25
+ /** The parts of a HubSession a HubLink drives. */
26
+ export type HubLinkSession = Pick<
27
+ HubSession,
28
+ "connect" | "disconnect" | "isConnected" | "whenDisconnected"
29
+ >;
30
+
31
+ export interface HubLinkOptions {
32
+ hub: Readonly<HubLinkSession>;
33
+ /** The hub to hold a session on next, given the URLs whose last dial or session failed. Read before every dial and every poll interval while connected. */
34
+ selectUrl: (excluded: ReadonlySet<string>) => string;
35
+ /** Whether the store wants a hub session right now. Read every poll interval and on reconsider(); a session held when this turns false is dropped, and none is dialled while it is false. */
36
+ shouldConnect: () => boolean;
37
+ /** Called each time a session is established, after the dial resolves, with the URL it was established to. WireMeshTransport uses it to advertise this store's presence at once instead of waiting for the next presence tick, and to know whether it holds an uplink to the public hub. */
38
+ onConnected: (url: string) => void;
39
+ /** Called each time an established session ends, whoever ended it. */
40
+ onDisconnected: () => void;
41
+ /** Reports a failed dial. Never called for anything else. */
42
+ onError: (error: Error) => void;
43
+ /** Overrides HUB_RECONNECT_INITIAL_DELAY_MS, for a test that must not wait a second per redial. */
44
+ initialDelayMs?: number;
45
+ /** Overrides HUB_RECONNECT_MAX_DELAY_MS. */
46
+ maxDelayMs?: number;
47
+ /** Overrides HUB_LINK_POLL_INTERVAL_MS. */
48
+ pollIntervalMs?: number;
49
+ }
50
+
51
+ export class HubLink {
52
+ private readonly initialDelayMs: number;
53
+ private readonly maxDelayMs: number;
54
+ private readonly pollIntervalMs: number;
55
+ private stopped = false;
56
+ private running: Promise<void> | undefined;
57
+ /** Cancels the wait between dials, so stop() does not sit out a delay. */
58
+ private wake: (() => void) | undefined;
59
+ /** When each URL's last dial failed or its session was dropped by the far end (epoch ms); a successful dial to a URL clears its entry. */
60
+ private readonly failedAt = new Map<string, number>();
61
+
62
+ constructor(private readonly options: Readonly<HubLinkOptions>) {
63
+ this.initialDelayMs =
64
+ options.initialDelayMs ?? HUB_RECONNECT_INITIAL_DELAY_MS;
65
+ this.maxDelayMs = options.maxDelayMs ?? HUB_RECONNECT_MAX_DELAY_MS;
66
+ this.pollIntervalMs = options.pollIntervalMs ?? HUB_LINK_POLL_INTERVAL_MS;
67
+ }
68
+
69
+ /** Begins running in the background and returns at once. A second call while running does nothing. */
70
+ start(): void {
71
+ if (this.running !== undefined || this.stopped) return;
72
+ this.running = this.run();
73
+ }
74
+
75
+ /** Re-evaluates shouldConnect now instead of at the next poll, and cuts short any wait, including the delay before a redial. Call it when something shouldConnect reads has changed. */
76
+ reconsider(): void {
77
+ this.wake?.();
78
+ }
79
+
80
+ /** Stops, drops the live session, and resolves once the loop has ended. */
81
+ async stop(): Promise<void> {
82
+ this.stopped = true;
83
+ this.wake?.();
84
+ await this.options.hub.disconnect();
85
+ await this.running;
86
+ }
87
+
88
+ /** A method call rather than a direct property read, so TypeScript's control-flow narrowing does not treat the flag as staying false across the awaits in run(), during which stop() can set it. */
89
+ private isStopped(): boolean {
90
+ return this.stopped;
91
+ }
92
+
93
+ private async run(): Promise<void> {
94
+ let delayMs = this.initialDelayMs;
95
+ while (!this.isStopped()) {
96
+ if (!this.options.shouldConnect()) {
97
+ await this.sleep(this.pollIntervalMs);
98
+ continue;
99
+ }
100
+ let sessionEnded = false;
101
+ const url = this.options.selectUrl(this.excludedUrls());
102
+ try {
103
+ await this.options.hub.connect(url);
104
+ if (this.options.hub.isConnected) {
105
+ delayMs = this.initialDelayMs;
106
+ this.failedAt.delete(url);
107
+ this.options.onConnected(url);
108
+ sessionEnded = await this.holdWhileWanted(url);
109
+ this.options.onDisconnected();
110
+ }
111
+ } catch (error) {
112
+ this.options.onError(
113
+ error instanceof Error ? error : new Error(String(error)),
114
+ );
115
+ sessionEnded = true;
116
+ }
117
+ if (sessionEnded) this.failedAt.set(url, Date.now());
118
+ if (this.isStopped()) return;
119
+ if (!sessionEnded) continue;
120
+ await this.sleep(delayMs);
121
+ delayMs = Math.min(
122
+ delayMs * RECONNECT_DELAY_GROWTH_FACTOR,
123
+ this.maxDelayMs,
124
+ );
125
+ }
126
+ }
127
+
128
+ /** Holds the live session to url until the hub ends it, resolving true, or until the store stops wanting it or selection prefers another hub, when it drops the session itself and resolves false. */
129
+ private async holdWhileWanted(url: string): Promise<boolean> {
130
+ const hubEnded = this.options.hub
131
+ .whenDisconnected()
132
+ .then(() => "ended" as const);
133
+ while (!this.isStopped()) {
134
+ const outcome = await Promise.race([
135
+ hubEnded,
136
+ this.sleep(this.pollIntervalMs).then(() => "woke" as const),
137
+ ]);
138
+ if (outcome === "ended") return true;
139
+ if (this.isStopped()) return false;
140
+ if (
141
+ !this.options.shouldConnect() ||
142
+ this.options.selectUrl(this.excludedUrls()) !== url
143
+ ) {
144
+ await this.options.hub.disconnect();
145
+ return false;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+
151
+ /** The URLs that failed within the last redial ceiling. */
152
+ private excludedUrls(): ReadonlySet<string> {
153
+ const cutoff = Date.now() - this.maxDelayMs;
154
+ const excluded = new Set<string>();
155
+ for (const [url, at] of this.failedAt) {
156
+ if (at > cutoff) excluded.add(url);
157
+ else this.failedAt.delete(url);
158
+ }
159
+ return excluded;
160
+ }
161
+
162
+ /** Waits ms, or until reconsider() or stop() cuts it short. Each wait clears the wake handle only if it is still its own, since holdWhileWanted leaves a losing wait pending and its late end must not wipe the handle of the wait that replaced it. */
163
+ private async sleep(ms: number): Promise<void> {
164
+ let wakeThis: (() => void) | undefined;
165
+ await new Promise<void>((resolve) => {
166
+ const timer = setTimeout(resolve, ms);
167
+ timer.unref();
168
+ wakeThis = () => {
169
+ clearTimeout(timer);
170
+ resolve();
171
+ };
172
+ this.wake = wakeThis;
173
+ });
174
+ if (this.wake === wakeThis) this.wake = undefined;
175
+ }
176
+ }