agent-comms 9.1.0 → 9.1.2

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 (83) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/request-handling.d.ts +26 -0
  5. package/dist/bridges/user/web/request-handling.d.ts.map +1 -0
  6. package/dist/bridges/user/web/request-handling.js +92 -0
  7. package/dist/bridges/user/web/request-handling.js.map +1 -0
  8. package/dist/bridges/user/web/server.d.ts +4 -0
  9. package/dist/bridges/user/web/server.d.ts.map +1 -1
  10. package/dist/bridges/user/web/server.js +88 -53
  11. package/dist/bridges/user/web/server.js.map +1 -1
  12. package/dist/bridges/user/web/web-access.d.ts.map +1 -1
  13. package/dist/bridges/user/web/web-access.js +14 -1
  14. package/dist/bridges/user/web/web-access.js.map +1 -1
  15. package/dist/bridges/user/web/web-assets.d.ts +2 -1
  16. package/dist/bridges/user/web/web-assets.d.ts.map +1 -1
  17. package/dist/bridges/user/web/web-assets.js +2 -2
  18. package/dist/bridges/user/web/web-assets.js.map +1 -1
  19. package/dist/bridges/user/web/web-console-static.d.ts.map +1 -1
  20. package/dist/bridges/user/web/web-console-static.js +34 -22
  21. package/dist/bridges/user/web/web-console-static.js.map +1 -1
  22. package/dist/core/coordinator-role.d.ts +15 -2
  23. package/dist/core/coordinator-role.d.ts.map +1 -1
  24. package/dist/core/coordinator-role.js +109 -11
  25. package/dist/core/coordinator-role.js.map +1 -1
  26. package/dist/core/detached-work.d.ts +14 -0
  27. package/dist/core/detached-work.d.ts.map +1 -0
  28. package/dist/core/detached-work.js +40 -0
  29. package/dist/core/detached-work.js.map +1 -0
  30. package/dist/core/election-sessions.d.ts.map +1 -1
  31. package/dist/core/election-sessions.js +6 -6
  32. package/dist/core/election-sessions.js.map +1 -1
  33. package/dist/core/hub-session.d.ts +10 -1
  34. package/dist/core/hub-session.d.ts.map +1 -1
  35. package/dist/core/hub-session.js +34 -32
  36. package/dist/core/hub-session.js.map +1 -1
  37. package/dist/core/message-limits.d.ts +14 -0
  38. package/dist/core/message-limits.d.ts.map +1 -0
  39. package/dist/core/message-limits.js +22 -0
  40. package/dist/core/message-limits.js.map +1 -0
  41. package/dist/core/peer-lifecycle.d.ts +2 -0
  42. package/dist/core/peer-lifecycle.d.ts.map +1 -1
  43. package/dist/core/peer-lifecycle.js +9 -4
  44. package/dist/core/peer-lifecycle.js.map +1 -1
  45. package/dist/core/pending-connection.d.ts +20 -1
  46. package/dist/core/pending-connection.d.ts.map +1 -1
  47. package/dist/core/pending-connection.js +27 -0
  48. package/dist/core/pending-connection.js.map +1 -1
  49. package/dist/core/relay-server.d.ts +15 -1
  50. package/dist/core/relay-server.d.ts.map +1 -1
  51. package/dist/core/relay-server.js +70 -16
  52. package/dist/core/relay-server.js.map +1 -1
  53. package/dist/core/room-router.d.ts.map +1 -1
  54. package/dist/core/room-router.js +31 -10
  55. package/dist/core/room-router.js.map +1 -1
  56. package/dist/core/tool.d.ts.map +1 -1
  57. package/dist/core/tool.js +3 -0
  58. package/dist/core/tool.js.map +1 -1
  59. package/dist/core/transport-events.d.ts.map +1 -1
  60. package/dist/core/transport-events.js +9 -5
  61. package/dist/core/transport-events.js.map +1 -1
  62. package/dist/core/wire-mesh-transport.d.ts +2 -2
  63. package/dist/core/wire-mesh-transport.d.ts.map +1 -1
  64. package/dist/core/wire-mesh-transport.js +96 -115
  65. package/dist/core/wire-mesh-transport.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/bridges/user/web/request-handling.ts +118 -0
  68. package/src/bridges/user/web/server.ts +109 -54
  69. package/src/bridges/user/web/web-access.ts +14 -1
  70. package/src/bridges/user/web/web-assets.ts +7 -3
  71. package/src/bridges/user/web/web-console-static.ts +34 -26
  72. package/src/core/coordinator-role.ts +126 -7
  73. package/src/core/detached-work.ts +55 -0
  74. package/src/core/election-sessions.ts +8 -4
  75. package/src/core/hub-session.ts +63 -41
  76. package/src/core/message-limits.ts +29 -0
  77. package/src/core/peer-lifecycle.ts +10 -4
  78. package/src/core/pending-connection.ts +48 -1
  79. package/src/core/relay-server.ts +110 -23
  80. package/src/core/room-router.ts +36 -9
  81. package/src/core/tool.ts +3 -0
  82. package/src/core/transport-events.ts +9 -5
  83. package/src/core/wire-mesh-transport.ts +58 -63
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Work started and not awaited, which must never end the process by rejecting: a peer decides what its messages contain, so an exception while handling one is reported, not left as an unhandled rejection.
3
+ */
4
+
5
+ /**
6
+ * Attaches a rejection handler to `work`. A rejection goes to the error handler `getOnError` returns when it settles (read then, not now, so a handler assigned after the work started is the one used), and to stderr when there is none, so it is never silent.
7
+ */
8
+ export function reportRejection(
9
+ work: Readonly<Promise<unknown>>,
10
+ getOnError: () => ((error: Error) => void) | undefined,
11
+ ): void {
12
+ work.catch((error: unknown) => {
13
+ const failure = error instanceof Error ? error : new Error(String(error));
14
+ const onError = getOnError();
15
+ if (onError !== undefined) {
16
+ onError(failure);
17
+ return;
18
+ }
19
+ process.stderr.write(`agent-comms: ${failure.message}\n`);
20
+ });
21
+ }
22
+
23
+ function report(
24
+ error: unknown,
25
+ getOnError: () => ((error: Error) => void) | undefined,
26
+ ): void {
27
+ reportRejection(
28
+ Promise.reject(error instanceof Error ? error : new Error(String(error))),
29
+ getOnError,
30
+ );
31
+ }
32
+
33
+ /** What a handler of consumeEach answers: read the next item, or end the loop here. */
34
+ export type ConsumeStep = "continue" | "stop";
35
+
36
+ /**
37
+ * Reads `items` until the stream ends, handing each to `handle` in arrival order, one at a time. A failure of `handle` is reported through `getOnError` (see reportRejection) and the loop carries on: one item a peer sent that cannot be handled must not stop every later one on the same session. A handler that returns "stop" ends the loop at once, as `break` would. A failure of the stream itself ends the loop and is reported the same way. Never rejects.
38
+ */
39
+ export async function consumeEach<T>(
40
+ items: Readonly<AsyncIterable<T>>,
41
+ handle: (item: T) => Promise<ConsumeStep> | ConsumeStep,
42
+ getOnError: () => ((error: Error) => void) | undefined,
43
+ ): Promise<void> {
44
+ try {
45
+ for await (const item of items) {
46
+ try {
47
+ if ((await handle(item)) === "stop") return;
48
+ } catch (error) {
49
+ report(error, getOnError);
50
+ }
51
+ }
52
+ } catch (error) {
53
+ report(error, getOnError);
54
+ }
55
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { AcceptedMeshSession } from "wire-mesh-core/domain/mesh-session";
10
10
  import type { CoordinatorFrame } from "wire-mesh-core/generated/protocol";
11
+ import { consumeEach } from "./detached-work.js";
11
12
  import type { ConnectionHandle, TransportEvents } from "./transport.js";
12
13
 
13
14
  /** Where a session's outbound claims stand: whether a send is unresolved, and the newest claim that arrived meanwhile. */
@@ -39,13 +40,16 @@ export class ElectionSessions {
39
40
  session: AcceptedMeshSession,
40
41
  handle: Readonly<ConnectionHandle>,
41
42
  ): void {
42
- void (async () => {
43
- for await (const frame of session.coordinatorFrames) {
43
+ void consumeEach(
44
+ session.coordinatorFrames,
45
+ (frame) => {
44
46
  if (this.enrolled.has(session)) {
45
47
  this.events.onCoordinatorClaim(handle, frame);
46
48
  }
47
- }
48
- })();
49
+ return "continue";
50
+ },
51
+ () => this.events.onError,
52
+ );
49
53
  }
50
54
 
51
55
  /** Enrols a trusted machine-local session, then fires onElectionSessionEnrolled so this side tells the peer which claim it accepts. Both ends of every machine-local session enrol it, so each tells the other. */
@@ -2,6 +2,7 @@
2
2
  * HubSession -- the relay-hub connection mode (agent-comms#151), split from wire-mesh-transport.ts under the max-lines cap the same way connection-approval.ts and its siblings were. A hub connection (wss://mesh.exadev.io/) is a RELAY, not a coordinator: no connect_request/introduce approval applies. The session's own self-advert is forwarded by the hub to every other connected agent and the hub answers with a catch-up of everyone already there, so peers discover each other purely through gossip. Messages ride relay-connect pairings -- sendManageRequest's own targetDevice routing, which the session layer wraps as relay-data to exactly that device.
3
3
  */
4
4
 
5
+ import { consumeEach } from "./detached-work.js";
5
6
  import {
6
7
  acceptMeshSession,
7
8
  type AcceptedMeshSession,
@@ -205,59 +206,57 @@ export class HubSession {
205
206
  ownHex: string,
206
207
  ): Promise<void> {
207
208
  try {
208
- for await (const event of session.events) {
209
- if (this.deps.isShuttingDown()) break;
210
- const remoteEntries = await this.deps.admitEntries(
211
- event.directory.filter(
212
- (entry) => deviceIdToHex(entry.device) !== ownHex,
213
- ),
214
- );
215
- for (const entry of remoteEntries) {
216
- this.hubPeersKnown.add(deviceIdToHex(entry.device));
217
- }
218
- if (remoteEntries.length > 0) this.deps.onDirectory(remoteEntries);
219
- if (event.state.status === "closed") break;
220
- }
209
+ // An event that cannot be handled (the directory filter rejecting, say) is reported and the next is still read: ending the loop here would clear a session that is still live.
210
+ await consumeEach(
211
+ session.events,
212
+ async (event) => {
213
+ if (this.deps.isShuttingDown()) return "stop";
214
+ const remoteEntries = await this.deps.admitEntries(
215
+ event.directory.filter(
216
+ (entry) => deviceIdToHex(entry.device) !== ownHex,
217
+ ),
218
+ );
219
+ for (const entry of remoteEntries) {
220
+ this.hubPeersKnown.add(deviceIdToHex(entry.device));
221
+ }
222
+ if (remoteEntries.length > 0) this.deps.onDirectory(remoteEntries);
223
+ return event.state.status === "closed" ? "stop" : "continue";
224
+ },
225
+ () => this.deps.events.onError,
226
+ );
221
227
  } finally {
222
228
  if (this.session === session) this.clearSession();
223
229
  }
224
230
  }
225
231
 
226
- /** Consumes one hub session's inbound relayed manage-requests until it ends, dispatching each in arrival order to dispatchHubRequest -- see that function's own doc for the actual per-request trust decision. Split out purely so the decision itself is directly unit-testable against fake requests/deps (hub-session-dispatch.test.ts), the same reason hub-forwarding.ts's own routeRoomRequestViaHub is a standalone function rather than a private method. Passes this.url through as dispatchHubRequest's own hubAddress -- this side's own dialled address for the hub every request on this specific session arrived over (agent-comms#216), the answering-side equivalent of tracePath's own local.hubAddress above. */
232
+ /** Consumes one hub session's inbound relayed manage-requests until it ends, dispatching each in arrival order to dispatchHubRequest, and reporting a request whose dispatch fails without ending the loop, so one request cannot stop every later one on the session, see that function's own doc for the actual per-request trust decision. Split out purely so the decision itself is directly unit-testable against fake requests/deps (hub-session-dispatch.test.ts), the same reason hub-forwarding.ts's own routeRoomRequestViaHub is a standalone function rather than a private method. Passes this.url through as dispatchHubRequest's own hubAddress, this side's own dialled address for the hub every request on this specific session arrived over (agent-comms#216), the answering-side equivalent of tracePath's own local.hubAddress above. */
227
233
  private consume(session: AcceptedMeshSession): void {
228
- void (async () => {
229
- for await (const request of session.incomingManageRequests) {
230
- if (this.deps.isShuttingDown()) break;
231
- await dispatchHubRequest({
232
- request,
233
- deps: this.deps,
234
- onKnownPeer: (hex) => {
235
- this.hubPeersKnown.add(hex);
236
- },
237
- hubAddress: this.url,
238
- });
239
- }
240
- })();
234
+ void drainHubRequests({
235
+ requests: session.incomingManageRequests,
236
+ deps: this.deps,
237
+ onKnownPeer: (hex) => {
238
+ this.hubPeersKnown.add(hex);
239
+ },
240
+ hubAddress: this.url,
241
+ });
241
242
  }
242
243
 
243
244
  /** Consumes one hub session's inbound core/data frames until it ends, handing each one that came through a relay pairing to onRelayedDataFrame in arrival order. A failure in the handler is reported and the loop carries on, since one bad frame must not stop every later one. */
244
245
  private consumeDataFrames(session: AcceptedMeshSession): void {
245
- void (async () => {
246
- for await (const incoming of session.incomingDataFrames) {
247
- if (this.deps.isShuttingDown()) break;
248
- if (incoming.fromDevice === undefined) continue;
249
- await this.deps
250
- .onRelayedDataFrame(
246
+ void consumeEach(
247
+ session.incomingDataFrames,
248
+ async (incoming) => {
249
+ if (this.deps.isShuttingDown()) return "stop";
250
+ if (incoming.fromDevice !== undefined) {
251
+ await this.deps.onRelayedDataFrame(
251
252
  deviceIdToHex(incoming.fromDevice),
252
253
  incoming.frame,
253
- )
254
- .catch((error: unknown) => {
255
- this.deps.events.onError?.(
256
- error instanceof Error ? error : new Error(String(error)),
257
- );
258
- });
259
- }
260
- })();
254
+ );
255
+ }
256
+ return "continue";
257
+ },
258
+ () => this.deps.events.onError,
259
+ );
261
260
  }
262
261
 
263
262
  /** Sends one core/data frame to a specific hub-reachable device through a relay pairing, sealed end to end with that device. Rejects when no hub session is live. */
@@ -325,6 +324,29 @@ function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {
325
324
  return bytes;
326
325
  }
327
326
 
327
+ /**
328
+ * Dispatches each inbound relayed manage-request in arrival order until the stream ends. A request whose dispatch throws is reported through the transport's error handler and the next is still read, and a transport that is shutting down makes the loop skip the rest. Never rejects. Exported so the loop is tested without a live hub session.
329
+ */
330
+ export async function drainHubRequests(options: {
331
+ requests: AsyncIterable<IncomingManageRequest>;
332
+ deps: Readonly<
333
+ HubRequestDispatchDeps & Pick<HubSessionDeps, "events" | "isShuttingDown">
334
+ >;
335
+ onKnownPeer: (deviceHex: string) => void;
336
+ hubAddress?: string | undefined;
337
+ }): Promise<void> {
338
+ const { requests, deps, onKnownPeer, hubAddress } = options;
339
+ await consumeEach(
340
+ requests,
341
+ async (request) => {
342
+ if (deps.isShuttingDown()) return "stop";
343
+ await dispatchHubRequest({ request, deps, onKnownPeer, hubAddress });
344
+ return "continue";
345
+ },
346
+ () => deps.events.onError,
347
+ );
348
+ }
349
+
328
350
  /** The slice of HubSessionDeps dispatchHubRequest actually needs. Named so its own tests, and consume()'s call site, don't repeat the same Pick inline. */
329
351
  export type HubRequestDispatchDeps = Pick<
330
352
  HubSessionDeps,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The size limit shared by every path that takes message text from outside this process: the web bridge's HTTP bodies and WebSocket messages, and the `content` of a message sent through the MCP tool.
3
+ */
4
+
5
+ import { CommsError } from "./store.js";
6
+
7
+ const BYTES_PER_KIB = 1024;
8
+ const KIB_PER_MIB = 1024;
9
+
10
+ /**
11
+ * The most bytes the bridge reads from one HTTP request body, accepts in one dashboard WebSocket message, and accepts as the content of one message sent through the tool.
12
+ *
13
+ * The largest legitimate input is a chat message a person typed or pasted. One mebibyte holds a pasted document of several hundred thousand characters, even with every character escaped in a JSON action, which is well beyond anything a conversation carries, while keeping what one request can make the bridge hold in memory small. Every other request the dashboard sends (room, agent and read calls) is a few hundred bytes.
14
+ */
15
+ export const MAX_MESSAGE_BYTES = KIB_PER_MIB * BYTES_PER_KIB;
16
+
17
+ /** The `code` of the error raised for message content over MAX_MESSAGE_BYTES. */
18
+ export const MESSAGE_TOO_LARGE = "MESSAGE_TOO_LARGE";
19
+
20
+ /** Throws a CommsError, before anything is sent, when `content` is longer than MAX_MESSAGE_BYTES in UTF-8. The text of the error states the limit and the size, never the content. */
21
+ export function requireMessageWithinLimit(content: string): void {
22
+ const bytes = Buffer.byteLength(content, "utf-8");
23
+ if (bytes > MAX_MESSAGE_BYTES) {
24
+ throw new CommsError(
25
+ `The message is ${String(bytes)} bytes and the limit is ${String(MAX_MESSAGE_BYTES)} bytes; nothing was sent. Send it in shorter parts.`,
26
+ MESSAGE_TOO_LARGE,
27
+ );
28
+ }
29
+ }
@@ -4,6 +4,7 @@
4
4
  * Two roles are kept apart here (agent-comms#341). The well-known port listener is only the compatibility first-contact address and introduction path, and is still passed on by graceful handover and recovered by the bind race. The coordinator duties (the stale-agent probe, single-authority departure announcements, the default cc-peer front) follow the elected CoordinatorRole instead, whose holder need not be the peer that bound the port.
5
5
  */
6
6
 
7
+ import { reportRejection } from "./detached-work.js";
7
8
  import { normaliseWireState } from "./wire-protocol.js";
8
9
  import { parseIsoTimestampMs } from "./iso-timestamp.js";
9
10
  import type {
@@ -158,7 +159,7 @@ export class PeerLifecycle {
158
159
  this.deps.peerInfo.set(peer.id, peer);
159
160
  // The list always includes this store's own entry — dialling yourself is a wasted connection attempt (and, on some platforms, an immediate self-inflicted ECONNRESET) that never needs to happen.
160
161
  if (peer.id === peerId) continue;
161
- void this.deps.requireTransport().connectToPeer(peer, peerId);
162
+ this.detach(this.deps.requireTransport().connectToPeer(peer, peerId));
162
163
  }
163
164
  }
164
165
 
@@ -166,7 +167,7 @@ export class PeerLifecycle {
166
167
  this.deps.peerInfo.set(peer.id, peer);
167
168
  const peerId = this.deps.getPeerId();
168
169
  if (peer.id === peerId) return;
169
- void this.deps.requireTransport().connectToPeer(peer, peerId);
170
+ this.detach(this.deps.requireTransport().connectToPeer(peer, peerId));
170
171
  }
171
172
 
172
173
  async handleIntroduction(
@@ -242,13 +243,18 @@ export class PeerLifecycle {
242
243
  if (self !== undefined) this.deps.peerInfo.set(peerId, self);
243
244
  for (const peer of peerList) {
244
245
  this.deps.peerInfo.set(peer.id, peer);
245
- void this.deps.requireTransport().connectToPeer(peer, peerId);
246
+ this.detach(this.deps.requireTransport().connectToPeer(peer, peerId));
246
247
  }
247
248
  await this.claimWhileAlone();
248
249
  }
249
250
 
251
+ /** Work started for a peer and not awaited: a failure is reported through onError (or stderr), never left as an unhandled rejection. */
252
+ private detach(work: Readonly<Promise<unknown>>): void {
253
+ reportRejection(work, () => this.deps.onError);
254
+ }
255
+
250
256
  handlePeerDisconnected(handle: Readonly<ConnectionHandle>): void {
251
- void this.handlePeerDeparture(handle.id);
257
+ this.detach(this.handlePeerDeparture(handle.id));
252
258
  }
253
259
 
254
260
  /** The awaitable form of handlePeerDisconnected, which TransportEvents forces to be synchronous. A departing peer leaves the peer list. When it held the elected coordinator role, the role is recovered first (the lowest surviving device-id claims at a raised term), so the new holder is already in place by the time the departed peer's agent record is retired; when it held the well-known port this side dialled, the vacated port is contested too. */
@@ -6,7 +6,11 @@ import type {
6
6
  AcceptedMeshSession,
7
7
  IncomingManageRequest,
8
8
  } from "wire-mesh-core/domain/mesh-session";
9
- import type { ListenerPolicy } from "./transport.js";
9
+ import type {
10
+ ConnectionHandle,
11
+ ListenerPolicy,
12
+ TransportEvents,
13
+ } from "./transport.js";
10
14
 
11
15
  /** How long a connect_request may sit awaiting a human decision before this side gives up and rejects it automatically. Generous on purpose -- this bounds a human approval window, not a network timeout: 5 minutes covers a person genuinely being away from the terminal for a few minutes, while still guaranteeing every unanswered request eventually resolves instead of accumulating in pendingConnections indefinitely. */
12
16
  const PENDING_CONNECTION_TIMEOUT_MINUTES = 5;
@@ -51,3 +55,46 @@ export function expirePendingConnection(
51
55
  .catch(() => undefined);
52
56
  pending.resolve("reject");
53
57
  }
58
+
59
+ /** What holdConnectRequest needs: where pending requests are kept, how long one may wait, and where the request is announced. */
60
+ export interface HoldConnectRequestOptions {
61
+ pendingConnections: Map<string, PendingConnection>;
62
+ timeoutMs: number;
63
+ events: Pick<TransportEvents, "onConnectionRequest">;
64
+ }
65
+
66
+ /**
67
+ * Holds a connect_request open until a human decides, and settles with that decision. The request is announced through `onConnectionRequest` and answered later by acceptConnection or rejectConnection, or by expirePendingConnection if neither happens within the timeout. The timer is unref()'d so a stray pending connection, whose timer failed to be cleared through some path, can never by itself keep the process alive for up to the timeout after everything else is done.
68
+ */
69
+ export async function holdConnectRequest(
70
+ options: Readonly<HoldConnectRequestOptions>,
71
+ held: Readonly<{
72
+ request: IncomingManageRequest;
73
+ message: { dataPort: number; name: string; fingerprint: string };
74
+ handle: Readonly<ConnectionHandle>;
75
+ session: AcceptedMeshSession;
76
+ }>,
77
+ ): Promise<ConnectionDecision> {
78
+ const { pendingConnections, timeoutMs, events } = options;
79
+ const { request, message, handle, session } = held;
80
+ return new Promise<ConnectionDecision>((resolve) => {
81
+ pendingConnections.set(handle.id, {
82
+ respond: request.respond,
83
+ dataPort: message.dataPort,
84
+ name: message.name,
85
+ fingerprint: message.fingerprint,
86
+ policy: handle.policy,
87
+ session,
88
+ resolve,
89
+ timeoutHandle: setTimeout(() => {
90
+ expirePendingConnection(pendingConnections, handle.id);
91
+ }, timeoutMs).unref(),
92
+ });
93
+ events.onConnectionRequest(handle, {
94
+ peerId: handle.id,
95
+ dataPort: message.dataPort,
96
+ name: message.name,
97
+ fingerprint: message.fingerprint,
98
+ });
99
+ });
100
+ }
@@ -4,6 +4,11 @@
4
4
  * Admission is deny-by-default, like every other cross-machine decision here: a connection is registered with the hub only once it gossips an advert for a device isAdmitted accepts, and every gossiped advert naming a device it does not accept is dropped before the hub sees it. A connection that never gossips an admitted device can neither be named by a relay-connect nor relay anything, and learns nothing, since the hub's catch-up is sent only in answer to a registered connection's gossip.
5
5
  */
6
6
 
7
+ import {
8
+ consumeEach,
9
+ reportRejection,
10
+ type ConsumeStep,
11
+ } from "./detached-work.js";
7
12
  import { createServer, type Server } from "node:http";
8
13
  import * as os from "node:os";
9
14
  import { WebSocketServer } from "ws";
@@ -21,6 +26,25 @@ import { createUplink, type UplinkFeed } from "./relay-uplink.js";
21
26
  /** The wildcard IPv4 address: listening on it serves every interface, loopback and the local network alike. */
22
27
  export const ALL_INTERFACES_HOST = "0.0.0.0";
23
28
 
29
+ const BYTES_PER_KIB = 1024;
30
+ const KIB_PER_MIB = 1024;
31
+ const RELAY_FRAME_MIB = 16;
32
+
33
+ /**
34
+ * The most bytes one WebSocket message may carry to the relay: 16 MiB. It is above the largest frame a legitimate client sends (a full reply of account ledger entries, about 12.6 MiB), equal to the receive cap of the Rust stream transport, and below the 32 MiB the hosted hub accepts, so no frame a conforming peer sends is refused and the relay holds no more of one connection's message than the hub would. The WebSocket server enforces it on the length a frame declares, before its payload is read: a message over it closes that connection with 1009 and nothing else.
35
+ */
36
+ export const MAX_RELAY_FRAME_BYTES =
37
+ RELAY_FRAME_MIB * KIB_PER_MIB * BYTES_PER_KIB;
38
+
39
+ /** How long a client of the relay has to send its request headers and to complete a request: ample for any real client on a local network, and short because every such client is unauthenticated until it has gossiped an admitted device. */
40
+ export const RELAY_REQUEST_TIMEOUT_MS = 10_000;
41
+
42
+ /** How long a WebSocket client of the relay has, from connecting, to gossip an advert for an admitted device. A connection that has not is closed: it can neither be named by a relay-connect nor relay anything, so holding it open serves no one. */
43
+ export const RELAY_ADMISSION_TIMEOUT_MS = 30_000;
44
+
45
+ /** HTTP status for a request to the relay that is not a WebSocket upgrade: the relay speaks nothing else. */
46
+ const HTTP_UPGRADE_REQUIRED = 426;
47
+
24
48
  /** The loopback address every relay also offers, so a peer on the same machine reaches it without leaving the host. */
25
49
  const LOOPBACK_HOST = "127.0.0.1";
26
50
 
@@ -31,6 +55,12 @@ export interface RelayServerOptions {
31
55
  isAdmitted: (deviceHex: string) => boolean;
32
56
  /** Reports a failure of the uplink, which is otherwise detached from any caller. */
33
57
  onError: (error: Error) => void;
58
+ /** Overrides RELAY_REQUEST_TIMEOUT_MS, the time a client has to send its headers and complete a request. A test sets it small. */
59
+ requestTimeoutMs?: number | undefined;
60
+ /** Overrides RELAY_ADMISSION_TIMEOUT_MS, the time a WebSocket client has to gossip an admitted device. A test sets it small. */
61
+ admissionTimeoutMs?: number | undefined;
62
+ /** Overrides how often Node checks connections against the request timeouts (its default is thirty seconds, which makes a timeout take effect up to that much late). A test sets it small. */
63
+ connectionsCheckingIntervalMs?: number | undefined;
34
64
  }
35
65
 
36
66
  export class RelayServer {
@@ -39,16 +69,58 @@ export class RelayServer {
39
69
  });
40
70
  /** The devices (hex) each connected client has registered, so an advert for one of them arriving from upstream never displaces the client's own registration. */
41
71
  private readonly clientDevices = new Map<Readonly<Connection>, Set<string>>();
42
- private readonly http: Server = createServer();
43
- private readonly wss = new WebSocketServer({ server: this.http });
72
+ private readonly http: Server;
73
+ private readonly wss: WebSocketServer;
44
74
  private port = 0;
45
75
  /** Settles once the previous uplink has been forgotten by the hub, which refuses a second while one is attached. */
46
76
  private uplinkDone: Promise<void> = Promise.resolve();
47
77
 
48
78
  constructor(private readonly options: Readonly<RelayServerOptions>) {
79
+ const requestTimeoutMs =
80
+ options.requestTimeoutMs ?? RELAY_REQUEST_TIMEOUT_MS;
81
+ // A request that is not a WebSocket upgrade is answered at once and its connection closed, and a client that sends headers or a request too slowly is cut off by the timeouts: nothing unauthenticated holds a connection open for ever.
82
+ this.http = createServer(
83
+ {
84
+ requestTimeout: requestTimeoutMs,
85
+ headersTimeout: requestTimeoutMs,
86
+ ...(options.connectionsCheckingIntervalMs !== undefined && {
87
+ connectionsCheckingInterval: options.connectionsCheckingIntervalMs,
88
+ }),
89
+ },
90
+ (_request, response) => {
91
+ response.writeHead(HTTP_UPGRADE_REQUIRED, {
92
+ "Content-Type": "text/plain; charset=utf-8",
93
+ Connection: "close",
94
+ Upgrade: "websocket",
95
+ });
96
+ response.end("Upgrade required");
97
+ },
98
+ );
99
+ this.wss = new WebSocketServer({
100
+ server: this.http,
101
+ maxPayload: MAX_RELAY_FRAME_BYTES,
102
+ });
103
+ // The WebSocket server re-emits an error of its listener; with no handler it would end the process.
104
+ this.wss.on("error", (error) => {
105
+ this.options.onError(error);
106
+ });
49
107
  this.wss.on("connection", (socket) => {
50
108
  socket.binaryType = "arraybuffer";
51
- void this.serve(wrapWsSocket(socket));
109
+ // The error event a message over the size limit raises is this connection's alone: reported, never left to end the process.
110
+ socket.on("error", (error) => {
111
+ this.options.onError(error);
112
+ });
113
+ const admission = setTimeout(() => {
114
+ socket.terminate();
115
+ }, options.admissionTimeoutMs ?? RELAY_ADMISSION_TIMEOUT_MS);
116
+ reportRejection(
117
+ this.serve(wrapWsSocket(socket), () => {
118
+ clearTimeout(admission);
119
+ }).finally(() => {
120
+ clearTimeout(admission);
121
+ }),
122
+ () => this.options.onError,
123
+ );
52
124
  });
53
125
  }
54
126
 
@@ -130,32 +202,47 @@ export class RelayServer {
130
202
  });
131
203
  }
132
204
 
133
- /** Drives one connection: filters its gossip to admitted devices, registers it with the hub on its first admitted advert, and hands the hub every frame from then on. */
134
- private async serve(connection: Readonly<Connection>): Promise<void> {
135
- let registered = false;
205
+ /** Drives one connection: filters its gossip to admitted devices, registers it with the hub on its first admitted advert and calls `onVerified` once the hub has accepted an advert of it, and hands the hub every frame from then on. A frame that cannot be handled is reported and the next is still read, so one bad frame never ends a connection's service; the loop ends when the connection's own stream does. */
206
+ private async serve(
207
+ connection: Readonly<Connection>,
208
+ onVerified: () => void,
209
+ ): Promise<void> {
210
+ const state = { registered: false, verified: false };
136
211
  const devices = new Set<string>();
137
212
  this.clientDevices.set(connection, devices);
138
213
  try {
139
- for await (const frame of connection.receive()) {
140
- const admitted = this.admit(frame);
141
- if (admitted?.type === "gossip") {
142
- for (const advert of admitted.peers) {
143
- devices.add(deviceIdToHex(advert.device));
214
+ await consumeEach(
215
+ connection.receive(),
216
+ async (frame): Promise<ConsumeStep> => {
217
+ const admitted = this.admit(frame);
218
+ if (admitted?.type === "gossip") {
219
+ for (const advert of admitted.peers) {
220
+ devices.add(deviceIdToHex(advert.device));
221
+ }
144
222
  }
145
- }
146
- if (admitted === undefined) continue;
147
- if (!registered) {
148
- if (admitted.type !== "gossip") continue;
149
- this.hub.registerConnection(connection);
150
- registered = true;
151
- }
152
- await this.hub.onFrame(connection, admitted);
153
- }
154
- } catch {
155
- // A connection that fails mid-stream is simply gone; its registration is cleaned up below.
223
+ if (admitted === undefined) return "continue";
224
+ if (!state.registered) {
225
+ if (admitted.type !== "gossip") return "continue";
226
+ this.hub.registerConnection(connection);
227
+ state.registered = true;
228
+ }
229
+ await this.hub.onFrame(connection, admitted);
230
+ // The hub verifies an advert after the connection is registered on the device id it names: the key must hash to that device and the signature must verify, or the advert is dropped. Only a connection the hub has accepted something from has proved anything, so only then is its admission deadline lifted.
231
+ if (
232
+ !state.verified &&
233
+ this.hub.exportConnection(connection) !== undefined
234
+ ) {
235
+ state.verified = true;
236
+ onVerified();
237
+ }
238
+ return "continue";
239
+ },
240
+ () => this.options.onError,
241
+ );
156
242
  } finally {
157
243
  this.clientDevices.delete(connection);
158
- if (registered) this.hub.onDisconnect(connection);
244
+ if (state.registered) this.hub.onDisconnect(connection);
245
+ await connection.close().catch(() => undefined);
159
246
  }
160
247
  }
161
248
 
@@ -11,6 +11,7 @@ import type {
11
11
  IncomingManageRequest,
12
12
  ManageOutcome,
13
13
  } from "wire-mesh-core/domain/mesh-session";
14
+ import { consumeEach, type ConsumeStep } from "./detached-work.js";
14
15
  import { isMeshMessage } from "./wire-protocol.js";
15
16
  import type { MeshMessage } from "./wire-protocol.js";
16
17
  import type { ConnectionHandle, TransportEvents } from "./transport.js";
@@ -128,9 +129,13 @@ export function createRoomRouter(options: RoomRouterOptions): RoomRouter {
128
129
  origin: Readonly<RoomRequestOrigin>,
129
130
  ): Promise<void> {
130
131
  const verb = extractParamsVerb(request.command.params);
132
+ // The verb is what a peer sent: look it up among the handlers' own names only, never the members every object has (`constructor`, `toString`), which would be called as a handler.
131
133
  const handler =
132
- request.command.verb !== FRAME_VERB && verb !== undefined
133
- ? options.handlers?.[verb]
134
+ request.command.verb !== FRAME_VERB &&
135
+ verb !== undefined &&
136
+ options.handlers !== undefined &&
137
+ Object.hasOwn(options.handlers, verb)
138
+ ? options.handlers[verb]
134
139
  : undefined;
135
140
  if (handler === undefined) {
136
141
  await request
@@ -138,7 +143,16 @@ export function createRoomRouter(options: RoomRouterOptions): RoomRouter {
138
143
  .catch(() => undefined);
139
144
  return;
140
145
  }
141
- const outcome = await handler(request, handle, origin);
146
+ // A handler that throws on what a peer sent answers that request with an error and is reported; it must not end the loop that reads this session's requests, or the process.
147
+ let outcome: ManageOutcome;
148
+ try {
149
+ outcome = await handler(request, handle, origin);
150
+ } catch (err) {
151
+ options.events.onError?.(
152
+ err instanceof Error ? err : new Error(String(err)),
153
+ );
154
+ outcome = { result: "error", code: "internal_error" };
155
+ }
142
156
  await request.respond(outcome).catch(() => undefined);
143
157
  }
144
158
 
@@ -149,8 +163,18 @@ export function createRoomRouter(options: RoomRouterOptions): RoomRouter {
149
163
  ): Promise<void> {
150
164
  if (request.command.verb === FRAME_VERB) {
151
165
  const message = extractMessage(request.command);
152
- if (message !== undefined) {
153
- routeLegacyMessage(handle, message, options.events);
166
+ try {
167
+ if (message !== undefined) {
168
+ routeLegacyMessage(handle, message, options.events);
169
+ }
170
+ } catch (err) {
171
+ options.events.onError?.(
172
+ err instanceof Error ? err : new Error(String(err)),
173
+ );
174
+ await request
175
+ .respond({ result: "error", code: "internal_error" })
176
+ .catch(() => undefined);
177
+ return;
154
178
  }
155
179
  await request.respond({ result: "ok" }).catch(() => undefined);
156
180
  return;
@@ -170,11 +194,14 @@ export function createRoomRouter(options: RoomRouterOptions): RoomRouter {
170
194
  handleLocalRequest,
171
195
  handleRelayedRequest,
172
196
  drainSession(session, handle) {
173
- void (async () => {
174
- for await (const request of session.incomingManageRequests) {
197
+ void consumeEach(
198
+ session.incomingManageRequests,
199
+ async (request): Promise<ConsumeStep> => {
175
200
  await handleLocalRequest(request, handle);
176
- }
177
- })();
201
+ return "continue";
202
+ },
203
+ () => options.events.onError,
204
+ );
178
205
  },
179
206
  };
180
207
  }
package/src/core/tool.ts CHANGED
@@ -37,6 +37,7 @@ import type { CommsStore } from "./comms-store.js";
37
37
  import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
38
38
  import type { DiscoveryManager } from "./discovery.js";
39
39
  import { CommsError } from "./store.js";
40
+ import { requireMessageWithinLimit } from "./message-limits.js";
40
41
  import type { LedgerGaps } from "./account-ledger.js";
41
42
  import { getOwnPackageVersion } from "./package-version.js";
42
43
  import { describeDeliveryFor } from "./send-outcome.js";
@@ -614,6 +615,7 @@ export class CommsTool {
614
615
  ctx: Readonly<CommsContext>,
615
616
  action: CommsAction & { action: "send" },
616
617
  ): Promise<CommsResult> {
618
+ requireMessageWithinLimit(action.content);
617
619
  const roomId = action.target;
618
620
  const { message, deliveries } = await this.store.sendRoomMessage(
619
621
  roomId,
@@ -650,6 +652,7 @@ export class CommsTool {
650
652
  ctx: Readonly<CommsContext>,
651
653
  action: CommsAction & { action: "dm" },
652
654
  ): Promise<CommsResult> {
655
+ requireMessageWithinLimit(action.content);
653
656
  const targetId = action.target;
654
657
  const { message, delivery } = await this.store.sendDm(
655
658
  ctx.agentId,