agent-comms 9.1.1 → 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 (78) 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/detached-work.d.ts +14 -0
  23. package/dist/core/detached-work.d.ts.map +1 -0
  24. package/dist/core/detached-work.js +40 -0
  25. package/dist/core/detached-work.js.map +1 -0
  26. package/dist/core/election-sessions.d.ts.map +1 -1
  27. package/dist/core/election-sessions.js +6 -6
  28. package/dist/core/election-sessions.js.map +1 -1
  29. package/dist/core/hub-session.d.ts +10 -1
  30. package/dist/core/hub-session.d.ts.map +1 -1
  31. package/dist/core/hub-session.js +34 -32
  32. package/dist/core/hub-session.js.map +1 -1
  33. package/dist/core/message-limits.d.ts +14 -0
  34. package/dist/core/message-limits.d.ts.map +1 -0
  35. package/dist/core/message-limits.js +22 -0
  36. package/dist/core/message-limits.js.map +1 -0
  37. package/dist/core/peer-lifecycle.d.ts +2 -0
  38. package/dist/core/peer-lifecycle.d.ts.map +1 -1
  39. package/dist/core/peer-lifecycle.js +9 -4
  40. package/dist/core/peer-lifecycle.js.map +1 -1
  41. package/dist/core/pending-connection.d.ts +20 -1
  42. package/dist/core/pending-connection.d.ts.map +1 -1
  43. package/dist/core/pending-connection.js +27 -0
  44. package/dist/core/pending-connection.js.map +1 -1
  45. package/dist/core/relay-server.d.ts +15 -1
  46. package/dist/core/relay-server.d.ts.map +1 -1
  47. package/dist/core/relay-server.js +70 -16
  48. package/dist/core/relay-server.js.map +1 -1
  49. package/dist/core/room-router.d.ts.map +1 -1
  50. package/dist/core/room-router.js +31 -10
  51. package/dist/core/room-router.js.map +1 -1
  52. package/dist/core/tool.d.ts.map +1 -1
  53. package/dist/core/tool.js +3 -0
  54. package/dist/core/tool.js.map +1 -1
  55. package/dist/core/transport-events.d.ts.map +1 -1
  56. package/dist/core/transport-events.js +9 -5
  57. package/dist/core/transport-events.js.map +1 -1
  58. package/dist/core/wire-mesh-transport.d.ts +2 -2
  59. package/dist/core/wire-mesh-transport.d.ts.map +1 -1
  60. package/dist/core/wire-mesh-transport.js +96 -115
  61. package/dist/core/wire-mesh-transport.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/bridges/user/web/request-handling.ts +118 -0
  64. package/src/bridges/user/web/server.ts +109 -54
  65. package/src/bridges/user/web/web-access.ts +14 -1
  66. package/src/bridges/user/web/web-assets.ts +7 -3
  67. package/src/bridges/user/web/web-console-static.ts +34 -26
  68. package/src/core/detached-work.ts +55 -0
  69. package/src/core/election-sessions.ts +8 -4
  70. package/src/core/hub-session.ts +63 -41
  71. package/src/core/message-limits.ts +29 -0
  72. package/src/core/peer-lifecycle.ts +10 -4
  73. package/src/core/pending-connection.ts +48 -1
  74. package/src/core/relay-server.ts +110 -23
  75. package/src/core/room-router.ts +36 -9
  76. package/src/core/tool.ts +3 -0
  77. package/src/core/transport-events.ts +9 -5
  78. package/src/core/wire-mesh-transport.ts +58 -63
@@ -0,0 +1,118 @@
1
+ /**
2
+ * How the web bridge reads a request body and answers a route whose work fails: every body has a maximum size, every failure of a route's asynchronous work becomes a response, and nothing a client sends can end the process.
3
+ */
4
+
5
+ import type * as http from "node:http";
6
+ import { MAX_MESSAGE_BYTES } from "../../../core/message-limits.js";
7
+ import { CommsError } from "../../../core/store.js";
8
+
9
+ const HTTP_BAD_REQUEST = 400;
10
+ const BYTES_PER_KIB = 1024;
11
+ const HTTP_PAYLOAD_TOO_LARGE = 413;
12
+ const HTTP_INTERNAL_SERVER_ERROR = 500;
13
+
14
+ const MS_PER_SECOND = 1000;
15
+ /** The slowest rate at which a client is still given time to send a body: 32 KiB a second, about a hundredth of a modest uplink, so a slow but moving client is served and one that sends a few bytes and stops is not. */
16
+ const MIN_BODY_RATE_KIB_PER_SECOND = 32;
17
+ const MIN_BODY_RATE_BYTES_PER_SECOND =
18
+ MIN_BODY_RATE_KIB_PER_SECOND * BYTES_PER_KIB;
19
+
20
+ /** A request the bridge refuses with a specific status and message, rather than failing it as a server fault. */
21
+ export class RequestRefusedError extends Error {
22
+ constructor(
23
+ public readonly status: number,
24
+ message: string,
25
+ ) {
26
+ super(message);
27
+ this.name = "RequestRefusedError";
28
+ }
29
+ }
30
+
31
+ /** A body that is not valid JSON, or not a JSON object. */
32
+ export function invalidJsonBody(): RequestRefusedError {
33
+ return new RequestRefusedError(HTTP_BAD_REQUEST, "Invalid JSON");
34
+ }
35
+
36
+ /**
37
+ * Reads a request body of at most MAX_MESSAGE_BYTES. Rejects with RequestRefusedError (413) as soon as the declared length, or the bytes actually received, pass the limit, without reading on: the caller answers and the connection is closed, so the rest of an oversized body is never buffered.
38
+ */
39
+ export async function readBoundedBody(
40
+ req: http.IncomingMessage,
41
+ ): Promise<string> {
42
+ const declared = Number(req.headers["content-length"]);
43
+ if (Number.isFinite(declared) && declared > MAX_MESSAGE_BYTES) {
44
+ throw tooLarge();
45
+ }
46
+ return new Promise((resolve, reject) => {
47
+ const chunks: Buffer[] = [];
48
+ let received = 0;
49
+ req.on("data", (chunk: Buffer) => {
50
+ received += chunk.length;
51
+ if (received > MAX_MESSAGE_BYTES) {
52
+ chunks.length = 0;
53
+ reject(tooLarge());
54
+ return;
55
+ }
56
+ chunks.push(chunk);
57
+ });
58
+ req.on("end", () => {
59
+ resolve(Buffer.concat(chunks).toString());
60
+ });
61
+ req.on("error", reject);
62
+ });
63
+ }
64
+
65
+ /**
66
+ * How long a client has to send a whole request, headers and body: the time a body of the largest size takes at the slowest accepted rate. A request not complete by then is answered 408 and its connection closed, so a client that declares a body and stops sending cannot hold a handler and its buffered bytes for Node's default of five minutes.
67
+ */
68
+ export const REQUEST_TIMEOUT_MS =
69
+ (MAX_MESSAGE_BYTES / MIN_BODY_RATE_BYTES_PER_SECOND) * MS_PER_SECOND;
70
+
71
+ function tooLarge(): RequestRefusedError {
72
+ return new RequestRefusedError(
73
+ HTTP_PAYLOAD_TOO_LARGE,
74
+ `Request body exceeds ${String(MAX_MESSAGE_BYTES)} bytes`,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Runs a route's asynchronous work and answers the request with its failure, so no rejection is left unhandled. A refusal keeps its status and message; anything else is a 500 with a fixed message (the error itself goes to `report`), since an unexpected error's text is not for the client. An answer that has already begun is ended, not started again. A refusal for size also closes the connection, so the rest of the body is not read.
80
+ */
81
+ export function serveRoute(
82
+ req: http.IncomingMessage,
83
+ res: http.ServerResponse,
84
+ work: () => Promise<void>,
85
+ report: (error: Error) => void = reportToStderr,
86
+ ): void {
87
+ work().catch((err: unknown) => {
88
+ const refused = err instanceof RequestRefusedError ? err : undefined;
89
+ if (refused === undefined) {
90
+ report(err instanceof Error ? err : new Error(String(err)));
91
+ }
92
+ if (res.headersSent || res.writableEnded) {
93
+ res.end();
94
+ return;
95
+ }
96
+ res.writeHead(refused?.status ?? HTTP_INTERNAL_SERVER_ERROR, {
97
+ "Content-Type": "application/json",
98
+ ...(refused?.status === HTTP_PAYLOAD_TOO_LARGE
99
+ ? { Connection: "close" }
100
+ : {}),
101
+ });
102
+ res.end(
103
+ JSON.stringify({ error: refused?.message ?? "Internal server error" }),
104
+ () => {
105
+ if (refused?.status === HTTP_PAYLOAD_TOO_LARGE) req.destroy();
106
+ },
107
+ );
108
+ });
109
+ }
110
+
111
+ function reportToStderr(error: Error): void {
112
+ process.stderr.write(`agent-comms web UI: ${error.message}\n`);
113
+ }
114
+
115
+ /** The message of a failure a client may be told: a CommsError states what was refused and why, so it is shown. Anything else can quote a path or an address, so a client gets fixed text. */
116
+ export function meshErrorMessage(err: unknown): string {
117
+ return err instanceof CommsError ? err.message : "Internal server error";
118
+ }
@@ -18,6 +18,7 @@ import type { MeshStore } from "../../../core/mesh-store.js";
18
18
  import { CommsError } from "../../../core/store.js";
19
19
  import type { MeshStatePatch } from "../../../core/wire-protocol.js";
20
20
  import type { WebUrlStatus } from "../../../core/tool.js";
21
+ import { MAX_MESSAGE_BYTES } from "../../../core/message-limits.js";
21
22
  import {
22
23
  createRoomAction,
23
24
  declineInviteAction,
@@ -59,6 +60,13 @@ import {
59
60
  } from "./web-access.js";
60
61
  import { bridgePortRange } from "./bridge-port-range.js";
61
62
  import { startWebBeacon } from "./web-beacon.js";
63
+ import {
64
+ REQUEST_TIMEOUT_MS,
65
+ meshErrorMessage,
66
+ invalidJsonBody,
67
+ readBoundedBody,
68
+ serveRoute,
69
+ } from "./request-handling.js";
62
70
  import {
63
71
  createFrontendAssetLoader,
64
72
  FrontendNotBuiltError,
@@ -81,6 +89,10 @@ const frontendAssets = createFrontendAssetLoader(
81
89
  fileURLToPath(new URL("./dist", import.meta.url)),
82
90
  );
83
91
 
92
+ /** The body of the 503 for a frontend that was never built. */
93
+ const FRONTEND_NOT_BUILT_BODY =
94
+ "The web frontend is not built. A git checkout does not include the build output; install the npm package, or run `pnpm build:frontend` in a checkout with its development dependencies installed.";
95
+
84
96
  /**
85
97
  * The frontend build output, or undefined after answering 503 because the build is absent. The server module is imported by every bridge, so a checkout without the build must still start; only the routes that serve the frontend are unavailable.
86
98
  */
@@ -94,7 +106,8 @@ function frontendOrUnavailable(
94
106
  res.writeHead(HTTP_SERVICE_UNAVAILABLE, {
95
107
  "Content-Type": "text/plain; charset=utf-8",
96
108
  });
97
- res.end(error.message);
109
+ // Fixed text: the error's own message names the install path of this machine, which is not for a client.
110
+ res.end(FRONTEND_NOT_BUILT_BODY);
98
111
  return undefined;
99
112
  }
100
113
  }
@@ -244,6 +257,10 @@ export async function createWebServer(options?: {
244
257
  firstContactPort?: number | undefined;
245
258
  /** Overrides the hub a fresh controller's own coordinator role dials on takeover -- ChatController's own hubUrl, ignored when existingController is supplied since that controller already made its own choice. See ChatController's own hubUrl doc comment. */
246
259
  hubUrl?: string | undefined;
260
+ /** Overrides REQUEST_TIMEOUT_MS, the time a client has to send a whole request. A test sets it small to see the 408. */
261
+ requestTimeoutMs?: number | undefined;
262
+ /** Overrides how often Node checks connections against that timeout (its default is thirty seconds, so a timeout takes effect up to that much after it is due). A test sets it small. */
263
+ connectionsCheckingIntervalMs?: number | undefined;
247
264
  }): Promise<WebServerHandle> {
248
265
  const {
249
266
  port,
@@ -255,6 +272,8 @@ export async function createWebServer(options?: {
255
272
  coordinatorPort,
256
273
  firstContactPort,
257
274
  hubUrl,
275
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
276
+ connectionsCheckingIntervalMs,
258
277
  } = options ?? {};
259
278
  const controller =
260
279
  existingController ??
@@ -282,10 +301,33 @@ export async function createWebServer(options?: {
282
301
  localClientAddresses,
283
302
  };
284
303
 
285
- const server = http.createServer((req, res) => {
286
- if (!admitRequest(accessPolicy, req, res)) return;
287
- handleRequest(req, res, controller, webConsoleDist);
288
- });
304
+ const server = http.createServer(
305
+ {
306
+ requestTimeout: requestTimeoutMs,
307
+ headersTimeout: requestTimeoutMs,
308
+ ...(connectionsCheckingIntervalMs !== undefined && {
309
+ connectionsCheckingInterval: connectionsCheckingIntervalMs,
310
+ }),
311
+ },
312
+ (req, res) => {
313
+ // Every response, the fixed routes and the operator's web-console files alike, forbids a browser to guess a type other than the one declared.
314
+ res.setHeader("X-Content-Type-Options", "nosniff");
315
+ try {
316
+ if (!admitRequest(accessPolicy, req, res)) return;
317
+ handleRequest(req, res, controller, webConsoleDist);
318
+ } catch (err) {
319
+ // A request the synchronous routing cannot handle is answered, not thrown: an exception out of this listener would end the process.
320
+ process.stderr.write(
321
+ `agent-comms web UI: ${err instanceof Error ? err.message : String(err)}\n`,
322
+ );
323
+ if (!res.headersSent) {
324
+ jsonError(res, "Internal server error", HTTP_INTERNAL_SERVER_ERROR);
325
+ } else {
326
+ res.end();
327
+ }
328
+ }
329
+ },
330
+ );
289
331
 
290
332
  let boundPort: number;
291
333
  try {
@@ -308,10 +350,15 @@ export async function createWebServer(options?: {
308
350
  publisher.publish({ kind: "delivery", event });
309
351
  });
310
352
 
311
- const wss = new WebSocketServer({ noServer: true });
353
+ const wss = new WebSocketServer({
354
+ noServer: true,
355
+ maxPayload: MAX_MESSAGE_BYTES,
356
+ });
312
357
  const rpcHandler = new RPCHandler(meshRouter);
313
358
 
314
359
  server.on("upgrade", (req, socket, head) => {
360
+ // A socket error before or after the upgrade (a peer that resets, say) is that connection's alone.
361
+ socket.on("error", reportSocketError);
315
362
  const decision = authorise(accessPolicy, requestOf(req));
316
363
  if (decision.kind !== "allow") {
317
364
  const status =
@@ -333,6 +380,8 @@ export async function createWebServer(options?: {
333
380
  });
334
381
 
335
382
  wss.on("connection", (ws) => {
383
+ // A socket error (a message over the size limit closes the socket with code 1009 and raises one) is handled here: an error event with no listener would end the process.
384
+ ws.on("error", reportSocketError);
336
385
  // RPCHandler.upgrade() wants a DOM-shaped WebSocket (addEventListener's overload set), which "ws"'s own type declarations don't structurally satisfy (its addEventListener options param is narrower than DOM's) -- .message()/.close() accept the looser { send } shape "ws" does satisfy, and are the documented manual equivalent to .upgrade().
337
386
  const context = { controller, publisher };
338
387
  ws.on("message", (data, isBinary) => {
@@ -341,10 +390,10 @@ export async function createWebServer(options?: {
341
390
  const bytes = raw instanceof ArrayBuffer ? Buffer.from(raw) : raw;
342
391
  // Buffer's .buffer is typed ArrayBufferLike (Buffer can wrap a SharedArrayBuffer); RPCHandler.message() wants the narrower Uint8Array<ArrayBuffer> -- a fresh Uint8Array.from() copy is always backed by a plain ArrayBuffer, satisfying that exactly.
343
392
  const payload = isBinary ? Uint8Array.from(bytes) : bytes.toString();
344
- void rpcHandler.message(ws, payload, { context });
393
+ rpcHandler.message(ws, payload, { context }).catch(reportSocketError);
345
394
  });
346
395
  ws.on("close", () => {
347
- void rpcHandler.close(ws);
396
+ rpcHandler.close(ws).catch(reportSocketError);
348
397
  });
349
398
  });
350
399
 
@@ -362,6 +411,13 @@ export async function createWebServer(options?: {
362
411
  return { server, controller, wss, publisher, host, accessToken };
363
412
  }
364
413
 
414
+ /** Reports a failure of one WebSocket connection to stderr: it only ever affects that connection. */
415
+ function reportSocketError(err: unknown): void {
416
+ process.stderr.write(
417
+ `agent-comms web UI: socket error: ${err instanceof Error ? err.message : String(err)}\n`,
418
+ );
419
+ }
420
+
365
421
  function requestOf(req: http.IncomingMessage): {
366
422
  method: string;
367
423
  url: string;
@@ -454,12 +510,16 @@ export async function runWeb(userName: string, port?: number): Promise<void> {
454
510
  process.exit(0);
455
511
  };
456
512
 
457
- process.on("SIGINT", () => {
458
- void cleanup();
459
- });
460
- process.on("SIGTERM", () => {
461
- void cleanup();
462
- });
513
+ const shutDown = (): void => {
514
+ cleanup().catch((err: unknown) => {
515
+ process.stderr.write(
516
+ `agent-comms web UI: shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`,
517
+ );
518
+ process.exit(1);
519
+ });
520
+ };
521
+ process.on("SIGINT", shutDown);
522
+ process.on("SIGTERM", shutDown);
463
523
  }
464
524
 
465
525
  // ---------------------------------------------------------------------------
@@ -472,7 +532,14 @@ function handleRequest(
472
532
  controller: ChatController,
473
533
  webConsoleDist: string | undefined,
474
534
  ): void {
475
- const url = new URL(req.url ?? "/", `http://localhost`);
535
+ let url: URL;
536
+ try {
537
+ url = new URL(req.url ?? "/", `http://localhost`);
538
+ } catch {
539
+ // A request target that names no URL (an empty authority, say) is a bad request, not a fault.
540
+ jsonError(res, "Bad request", HTTP_BAD_REQUEST);
541
+ return;
542
+ }
476
543
 
477
544
  // wire-mesh's web-console — an alternate, generic reference-client UI, served only when AGENT_COMMS_WEB_CONSOLE_DIST resolves to a real build.
478
545
  if (
@@ -550,8 +617,8 @@ function handleRequest(
550
617
  const filename = url.pathname.slice("/icons/".length);
551
618
  const assets = frontendOrUnavailable(res);
552
619
  if (assets === undefined) return;
553
- const icon = assets.icons[filename];
554
- if (icon) {
620
+ const icon = assets.icons.get(filename);
621
+ if (icon !== undefined) {
555
622
  res.writeHead(HTTP_OK, {
556
623
  "Content-Type": "image/svg+xml",
557
624
  "Cache-Control": "public, max-age=604800",
@@ -563,18 +630,16 @@ function handleRequest(
563
630
 
564
631
  // API routes
565
632
  if (url.pathname === "/api/agents" && req.method === "GET") {
566
- void (async () => {
567
- const agents = await controller.getAgents();
568
- json(res, agents);
569
- })();
633
+ serveRoute(req, res, async () => {
634
+ json(res, await controller.getAgents());
635
+ });
570
636
  return;
571
637
  }
572
638
 
573
639
  if (url.pathname === "/api/rooms" && req.method === "GET") {
574
- void (async () => {
575
- const rooms = await controller.getRooms();
576
- json(res, rooms);
577
- })();
640
+ serveRoute(req, res, async () => {
641
+ json(res, await controller.getRooms());
642
+ });
578
643
  return;
579
644
  }
580
645
 
@@ -583,7 +648,7 @@ function handleRequest(
583
648
  url.pathname.endsWith("/messages") &&
584
649
  req.method === "GET"
585
650
  ) {
586
- void (async () => {
651
+ serveRoute(req, res, async () => {
587
652
  const roomId = url.pathname.split("/")[3];
588
653
  if (roomId === undefined || roomId === "") {
589
654
  jsonError(res, "Room ID required", HTTP_BAD_REQUEST);
@@ -595,7 +660,7 @@ function handleRequest(
595
660
  since,
596
661
  });
597
662
  json(res, messages);
598
- })();
663
+ });
599
664
  return;
600
665
  }
601
666
 
@@ -613,7 +678,7 @@ function handleRequest(
613
678
  }
614
679
 
615
680
  if (url.pathname === "/api/mesh/trace" && req.method === "GET") {
616
- void (async () => {
681
+ serveRoute(req, res, async () => {
617
682
  const target = url.searchParams.get("target");
618
683
  if (target === null || target === "") {
619
684
  jsonError(res, "target query parameter required", HTTP_BAD_REQUEST);
@@ -635,23 +700,31 @@ function handleRequest(
635
700
  meshErrorStatus(err, HTTP_BAD_REQUEST),
636
701
  );
637
702
  }
638
- })();
703
+ });
639
704
  return;
640
705
  }
641
706
 
642
707
  if (url.pathname === "/api/action" && req.method === "POST") {
643
- void (async () => {
644
- const body = await readBody(req);
645
- const parsed: unknown = JSON.parse(body);
646
- if (typeof parsed !== "object" || parsed === null) {
647
- jsonError(res, "Invalid JSON", HTTP_BAD_REQUEST);
648
- return;
708
+ serveRoute(req, res, async () => {
709
+ const body = await readBoundedBody(req);
710
+ let parsed: unknown;
711
+ try {
712
+ parsed = JSON.parse(body);
713
+ } catch {
714
+ throw invalidJsonBody();
715
+ }
716
+ if (
717
+ typeof parsed !== "object" ||
718
+ parsed === null ||
719
+ Array.isArray(parsed)
720
+ ) {
721
+ throw invalidJsonBody();
649
722
  }
650
723
  const params = Object.fromEntries(Object.entries(parsed));
651
724
 
652
725
  const result = await executeAction(controller, params);
653
726
  json(res, result);
654
- })();
727
+ });
655
728
  return;
656
729
  }
657
730
 
@@ -791,11 +864,6 @@ function jsonError(
791
864
  res.end(JSON.stringify({ error: message }));
792
865
  }
793
866
 
794
- /** Extracts a reportable message from whatever meshGraph()/meshTrace() threw -- a CommsError has a real message, anything else is reported generically rather than leaking an unexpected error shape to the client. */
795
- function meshErrorMessage(err: unknown): string {
796
- return err instanceof Error ? err.message : "Unknown error";
797
- }
798
-
799
867
  /** A transport that doesn't support mesh_graph/mesh_trace (CommsError code NOT_SUPPORTED, e.g. this bridge is running on FileStore rather than a real mesh) is a client-visible "this endpoint isn't available here" rather than a server fault. Any other error falls back to `otherwise` -- HTTP_BAD_REQUEST for mesh_trace, since its only other realistic failure is a malformed client-supplied target; HTTP_INTERNAL_SERVER_ERROR for mesh_graph, which takes no client input at all. */
800
868
  function meshErrorStatus(err: unknown, otherwise: number): number {
801
869
  if (err instanceof CommsError && err.code === "NOT_SUPPORTED") {
@@ -803,16 +871,3 @@ function meshErrorStatus(err: unknown, otherwise: number): number {
803
871
  }
804
872
  return otherwise;
805
873
  }
806
-
807
- async function readBody(req: http.IncomingMessage): Promise<string> {
808
- return new Promise((resolve, reject) => {
809
- const chunks: Buffer[] = [];
810
- req.on("data", (chunk: Buffer) => {
811
- chunks.push(chunk);
812
- });
813
- req.on("end", () => {
814
- resolve(Buffer.concat(chunks).toString());
815
- });
816
- req.on("error", reject);
817
- });
818
- }
@@ -32,6 +32,7 @@ const ACCESS_TOKEN_BYTES = 32;
32
32
  /** The port a Host header with no explicit port names (HTTP). */
33
33
  const HTTP_DEFAULT_PORT = 80;
34
34
 
35
+ const HTTP_BAD_REQUEST = 400;
35
36
  const HTTP_UNAUTHORIZED = 401;
36
37
  const HTTP_FORBIDDEN = 403;
37
38
  const HTTP_FOUND = 302;
@@ -360,7 +361,10 @@ export function authorise(
360
361
  return { kind: "allow" };
361
362
  }
362
363
  if (request.method === "GET") {
363
- const url = new URL(request.url, "http://placeholder");
364
+ const url = parseRequestUrl(request.url);
365
+ if (url === undefined) {
366
+ return { kind: "deny", status: HTTP_BAD_REQUEST, message: "Bad request" };
367
+ }
364
368
  const offered = url.searchParams.get(ACCESS_QUERY_PARAM);
365
369
  if (offered !== null && tokensMatch(offered, token)) {
366
370
  url.searchParams.delete(ACCESS_QUERY_PARAM);
@@ -375,6 +379,15 @@ export function authorise(
375
379
  return { kind: "deny", status: HTTP_UNAUTHORIZED, message: "Unauthorized" };
376
380
  }
377
381
 
382
+ /** The URL a request line names, or undefined when it names none: a request target that cannot be parsed is refused, never thrown out of the request listener, which would end the process. */
383
+ function parseRequestUrl(target: string): URL | undefined {
384
+ try {
385
+ return new URL(target, "http://placeholder");
386
+ } catch {
387
+ return undefined;
388
+ }
389
+ }
390
+
378
391
  /** The URLs an operator can open on the LAN, each carrying the token for the one-time exchange. A wildcard bind lists every external, non-link-local address of this machine that the bind family can reach (IPv6 bracketed, and only under `::`, since `0.0.0.0` accepts IPv4 alone); a specific bind lists that address. The list is empty when a wildcard bind finds no such address. */
379
392
  export function accessUrls(
380
393
  policy: Readonly<AccessPolicy> & { readonly token: string },
@@ -21,7 +21,8 @@ export interface FrontendAssets {
21
21
  meshWorkerJs: string;
22
22
  swJs: string;
23
23
  manifestJson: string;
24
- icons: Readonly<Record<string, Buffer>>;
24
+ /** The PWA icons by file name. A map, not an object: the name comes from the request path, and an object would also answer a name that is a member of every object (`constructor`, `toString`). */
25
+ icons: ReadonlyMap<string, Buffer>;
25
26
  }
26
27
 
27
28
  /** The frontend build output is absent from the directory it should be in. */
@@ -56,9 +57,12 @@ export function loadFrontendAssets(assetDir: string): FrontendAssets {
56
57
  const indexHtml = readIfPresent(path.join(assetDir, "index.html"));
57
58
  if (indexHtml === undefined) throw new FrontendNotBuiltError(assetDir);
58
59
 
59
- const icons: Record<string, Buffer> = {};
60
+ const icons = new Map<string, Buffer>();
60
61
  for (const filename of ICON_FILENAMES) {
61
- icons[filename] = fs.readFileSync(path.join(assetDir, "icons", filename));
62
+ icons.set(
63
+ filename,
64
+ fs.readFileSync(path.join(assetDir, "icons", filename)),
65
+ );
62
66
  }
63
67
 
64
68
  return {
@@ -14,21 +14,22 @@ export const WEB_CONSOLE_MOUNT = "/web-console";
14
14
  const HTTP_OK = 200;
15
15
  const HTTP_NOT_FOUND = 404;
16
16
 
17
- const CONTENT_TYPES: Record<string, string> = {
18
- ".html": "text/html; charset=utf-8",
19
- ".js": "application/javascript; charset=utf-8",
20
- ".mjs": "application/javascript; charset=utf-8",
21
- ".css": "text/css; charset=utf-8",
22
- ".json": "application/json; charset=utf-8",
23
- ".map": "application/json; charset=utf-8",
24
- ".svg": "image/svg+xml",
25
- ".png": "image/png",
26
- ".ico": "image/x-icon",
27
- ".woff": "font/woff",
28
- ".woff2": "font/woff2",
29
- ".txt": "text/plain; charset=utf-8",
30
- ".wasm": "application/wasm",
31
- };
17
+ /** Content type by extension. A map: the extension comes from the requested name, and an object would also answer a name that is a member of every object. */
18
+ const CONTENT_TYPES: ReadonlyMap<string, string> = new Map([
19
+ [".html", "text/html; charset=utf-8"],
20
+ [".js", "application/javascript; charset=utf-8"],
21
+ [".mjs", "application/javascript; charset=utf-8"],
22
+ [".css", "text/css; charset=utf-8"],
23
+ [".json", "application/json; charset=utf-8"],
24
+ [".map", "application/json; charset=utf-8"],
25
+ [".svg", "image/svg+xml"],
26
+ [".png", "image/png"],
27
+ [".ico", "image/x-icon"],
28
+ [".woff", "font/woff"],
29
+ [".woff2", "font/woff2"],
30
+ [".txt", "text/plain; charset=utf-8"],
31
+ [".wasm", "application/wasm"],
32
+ ]);
32
33
 
33
34
  const DEFAULT_CONTENT_TYPE = "application/octet-stream";
34
35
 
@@ -45,6 +46,20 @@ export function resolveWebConsoleDist(): string | undefined {
45
46
  return dist;
46
47
  }
47
48
 
49
+ /**
50
+ * The real path of `file`, when it is a regular file that lies inside `dist` once every symbolic link in both paths has been followed, and undefined for anything else: a missing file, a directory, a name that cannot be resolved, or a link, anywhere along the path, that leads outside the directory. The name is checked after resolution, never before, so a link inside the directory cannot be used to read a file outside it.
51
+ */
52
+ function realFileWithin(dist: string, file: string): string | undefined {
53
+ try {
54
+ const realDist = fs.realpathSync(dist);
55
+ const realFile = fs.realpathSync(file);
56
+ const inside = realFile.startsWith(`${realDist}${path.sep}`);
57
+ return inside && fs.statSync(realFile).isFile() ? realFile : undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
48
63
  /**
49
64
  * Serves a request under the web-console mount from a resolved dist directory.
50
65
  *
@@ -68,23 +83,16 @@ export function serveWebConsole(
68
83
 
69
84
  const resolvedDist = path.resolve(distDir);
70
85
  const resolvedFile = path.resolve(resolvedDist, relative);
71
- const withinDist =
72
- resolvedFile === resolvedDist ||
73
- resolvedFile.startsWith(`${resolvedDist}${path.sep}`);
74
-
75
- if (
76
- !withinDist ||
77
- !fs.existsSync(resolvedFile) ||
78
- !fs.statSync(resolvedFile).isFile()
79
- ) {
86
+ const servable = realFileWithin(resolvedDist, resolvedFile);
87
+ if (servable === undefined) {
80
88
  res.writeHead(HTTP_NOT_FOUND);
81
89
  res.end("Not found");
82
90
  return true;
83
91
  }
84
92
 
85
93
  const contentType =
86
- CONTENT_TYPES[path.extname(resolvedFile)] ?? DEFAULT_CONTENT_TYPE;
94
+ CONTENT_TYPES.get(path.extname(servable)) ?? DEFAULT_CONTENT_TYPE;
87
95
  res.writeHead(HTTP_OK, { "Content-Type": contentType });
88
- res.end(fs.readFileSync(resolvedFile));
96
+ res.end(fs.readFileSync(servable));
89
97
  return true;
90
98
  }
@@ -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. */