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
@@ -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
  }
@@ -3,12 +3,15 @@
3
3
  *
4
4
  * Claims travel as wire-mesh session frames (MeshTransport.broadcastCoordinatorClaim/sendCoordinatorClaim), and only over sessions to peers on this machine, because both duties the role assigns are facts about one machine: a PID probe can only see local processes and the cc-peer front can only reach local Claude Code sessions.
5
5
  *
6
- * When a claim is made: a store claims only when it knows of no incumbent and is the first on the machine as far as it can tell. A store that bound the well-known port with no first-contact presence configured can meet other stores only through that port, so it claims at once; one that may be reachable through first contact (it bound the port, or could neither join nor bind it) first waits one claim wait for an incumbent's announcement, so a store arriving at an existing mesh never takes the role at an equal term before the holder's claim reaches it. Both ends of every machine-local session announce the incumbent they know as the session is enrolled. When the incumbent departs, the lowest device-id among the peers this side has a machine-local session to claims at once at a raised term, and every other survivor claims too if, one claim wait later, the claim it accepts still names a holder it has no session to, which covers a lowest-id peer that is itself gone or was never reachable. The same wait follows accepting a claim whose holder this side has no session to, so a claim naming a device that never joined cannot pin the role vacant.
6
+ * When a claim is made: a store claims only when it knows of no incumbent and is the first on the machine as far as it can tell. A store that bound the well-known port with no first-contact presence configured can meet other stores only through that port, so it claims at once; one that may be reachable through first contact (it bound the port, or could neither join nor bind it) first waits one claim wait for an incumbent's announcement, so a store arriving at an existing mesh never takes the role at an equal term before the holder's claim reaches it. Both ends of every machine-local session announce the incumbent they know as the session is enrolled. When the incumbent departs, the lowest device-id among the peers this side has a machine-local session to claims at once at a raised term, and every other survivor claims too if, one claim wait later, the claim it accepts still names a holder it has no session to, which covers a lowest-id peer that is itself gone or was never reachable. A claim that is accepted names a holder that is here when it is accepted, so the wait after an accepted claim never starts: the claim waits are the one after a departure and the one at start-up.
7
+ *
8
+ * Whose claims count: claims are accepted from the peers this machine already trusts with a machine-local session, and this module does not authenticate them further. A claim is evaluated only when its holder is this side, the peer that sent it (the session's authenticated device-id, never a field of the frame), or a peer this side has a machine-local session to. A claim gossiped onward by a peer other than its holder is therefore still accepted wherever the holder is reachable, which is every peer of a fully connected machine. Any other claim waits until its holder's session is enrolled and is dropped after one claim wait, and is not evaluated at all when, by then, its sender has no machine-local session. Waiting claims are bounded per sender: each machine-local peer has at most PENDING_CLAIMS_PER_SENDER of them, at most one per holder, kept in the election's own order (higher term first, an equal term by lowest holder id), and a claim whose term no later claim could supersede is not kept.
7
9
  */
8
10
 
9
11
  import {
10
12
  CoordinatorElection,
11
13
  compareDeviceIds,
14
+ isSupersedableTerm,
12
15
  type CoordinatorClaim,
13
16
  } from "wire-mesh-core/domain/coordinator-election";
14
17
  import {
@@ -43,6 +46,14 @@ export interface CoordinatorRoleDeps {
43
46
  onError: (error: unknown) => void;
44
47
  }
45
48
 
49
+ /** Orders waiting claims as the election ranks claims: a higher term first, and at an equal term the lower holder id, compared as the election compares it. */
50
+ function rankedFirst(left: PendingClaim, right: PendingClaim): number {
51
+ if (left.frame.term !== right.frame.term) {
52
+ return right.frame.term - left.frame.term;
53
+ }
54
+ return compareDeviceIds(left.frame.coordinator, right.frame.coordinator);
55
+ }
56
+
46
57
  function sameClaim(
47
58
  frame: Readonly<CoordinatorFrame>,
48
59
  claim: Readonly<CoordinatorClaim>,
@@ -53,6 +64,17 @@ function sameClaim(
53
64
  );
54
65
  }
55
66
 
67
+ /** How many claims one machine-local peer may have waiting for their holders to enrol, one per holder. */
68
+ const PENDING_CLAIMS_PER_SENDER = 4;
69
+
70
+ /** A claim set aside because its holder is neither this side, its sender nor a peer with a session here, with who sent it and the timer that drops it after one claim wait. */
71
+ interface PendingClaim {
72
+ readonly handle: Readonly<ConnectionHandle>;
73
+ readonly frame: Readonly<CoordinatorFrame>;
74
+ readonly holder: string;
75
+ readonly timer: ReturnType<typeof setTimeout>;
76
+ }
77
+
56
78
  export class CoordinatorRole {
57
79
  /** Built on first use rather than at construction, because MeshStore's peerId is assigned after its constructor runs (every bridge sets it to the identity's device-id). */
58
80
  private electionInstance: CoordinatorElection | undefined;
@@ -67,6 +89,8 @@ export class CoordinatorRole {
67
89
  private takeoverStreak = 0;
68
90
  /** The claim, as `term:holder`, each peer was last answered with. A peer whose view lags keeps sending the claims it knows, and answering each one with the same incumbent only multiplies the traffic: the answer is repeated only when the incumbent changes or the peer's session is enrolled afresh, since the earlier answer may have been lost with the old session. */
69
91
  private readonly answered = new Map<string, string>();
92
+ /** The claims set aside, by the device-id of the session that sent each, best first in the election's own order. */
93
+ private readonly pending = new Map<string, PendingClaim[]>();
70
94
 
71
95
  constructor(private readonly deps: Readonly<CoordinatorRoleDeps>) {}
72
96
 
@@ -104,23 +128,42 @@ export class CoordinatorRole {
104
128
  /** Tells a peer whose machine-local session was just enrolled which claim this side accepts, so a joiner learns the incumbent instead of claiming over it. A no-op while this side knows of none. */
105
129
  async announceTo(handle: Readonly<ConnectionHandle>): Promise<void> {
106
130
  this.answered.delete(handle.id);
107
- const frame = this.electionInstance?.announceCurrent();
108
- if (frame === undefined) return;
109
- await this.deps.requireTransport().sendCoordinatorClaim(handle, frame);
131
+ // Taken before the first await, so a claim released here is neither dropped by its timer nor taken twice while the announcement is being sent.
132
+ const released = this.takePendingFor(handle.id);
133
+ try {
134
+ const frame = this.electionInstance?.announceCurrent();
135
+ if (frame !== undefined) {
136
+ await this.deps.requireTransport().sendCoordinatorClaim(handle, frame);
137
+ }
138
+ } catch (error) {
139
+ this.deps.onError(error);
140
+ }
141
+ // The session just enrolled may be the holder claims were set aside for. One claim failing to evaluate never costs the others theirs.
142
+ for (const waiting of released) {
143
+ try {
144
+ await this.handleClaim(waiting.handle, waiting.frame);
145
+ } catch (error) {
146
+ this.deps.onError(error);
147
+ }
148
+ }
110
149
  }
111
150
 
112
- /** Evaluates a claim a peer gossiped. An accepted claim is gossiped on to every peer so the supersession reaches peers the sender is not connected to, and when it names a holder this side has no session to, this side waits one claim wait for that holder to appear before claiming over it; a stale or tiebreak-losing claim is answered with the incumbent so its sender converges, and a claim whose term no later claim could supersede is dropped unevaluated, neither gossiped nor answered. Receiving the incumbent itself is neither, and is dropped without a reply, which is what stops two peers that agree from echoing it forever. */
151
+ /** Evaluates a claim a peer gossiped. An accepted claim is gossiped on to every peer so the supersession reaches peers the sender is not connected to, a claim whose holder is not here waits for that holder to enrol and is dropped after one claim wait; a stale or tiebreak-losing claim is answered with the incumbent so its sender converges, and a claim whose term no later claim could supersede is dropped unevaluated, neither gossiped nor answered. Receiving the incumbent itself is neither, and is dropped without a reply, which is what stops two peers that agree from echoing it forever. */
113
152
  async handleClaim(
114
153
  handle: Readonly<ConnectionHandle>,
115
154
  frame: Readonly<CoordinatorFrame>,
116
155
  ): Promise<void> {
117
156
  if (this.stopped) return;
157
+ if (!this.holderIsHere(handle, frame)) {
158
+ this.setAside(handle, frame);
159
+ return;
160
+ }
118
161
  const result = this.election.evaluate(frame);
119
162
  if (result.outcome === "accepted") {
120
163
  this.answered.clear();
121
164
  this.cancelClaimWait();
122
- if (this.incumbentUnreachable()) this.startClaimWait();
123
- else this.takeoverStreak = 0;
165
+ // The holder of an accepted claim is here, so there is no incumbent to wait out.
166
+ this.takeoverStreak = 0;
124
167
  await this.deps.requireTransport().broadcastCoordinatorClaim(frame);
125
168
  } else if (
126
169
  result.outcome === "retained" &&
@@ -155,9 +198,85 @@ export class CoordinatorRole {
155
198
  async stop(): Promise<void> {
156
199
  this.stopped = true;
157
200
  this.cancelClaimWait();
201
+ this.dropAllPending();
158
202
  await this.syncHolding();
159
203
  }
160
204
 
205
+ /** Whether the holder a claim names is this side, the peer that sent it, or a peer this side has a machine-local session to. The sender is the handle's authenticated device-id, so a peer cannot name itself as someone else. */
206
+ private holderIsHere(
207
+ handle: Readonly<ConnectionHandle>,
208
+ frame: Readonly<CoordinatorFrame>,
209
+ ): boolean {
210
+ const holder = deviceIdToHex(frame.coordinator);
211
+ return (
212
+ holder === this.deps.getPeerId() ||
213
+ holder === handle.id ||
214
+ this.deps.requireTransport().electionPeerIds().has(holder)
215
+ );
216
+ }
217
+
218
+ /** Keeps a claim whose holder is not here until that holder's session is enrolled, and drops it after one claim wait. The sender keeps at most PENDING_CLAIMS_PER_SENDER of them, one per holder, so a new claim for a holder already waiting replaces it only at a higher term, and when the sender is over its share the claim the election ranks last goes. */
219
+ private setAside(
220
+ handle: Readonly<ConnectionHandle>,
221
+ frame: Readonly<CoordinatorFrame>,
222
+ ): void {
223
+ // A claim whose term no later claim could supersede is dropped by the election unevaluated, wherever its holder is, so it never takes a place here.
224
+ if (!isSupersedableTerm(frame.term)) return;
225
+ const holder = deviceIdToHex(frame.coordinator);
226
+ const mine = this.pending.get(handle.id) ?? [];
227
+ const same = mine.find((entry) => entry.holder === holder);
228
+ if (same !== undefined) {
229
+ if (same.frame.term >= frame.term) return;
230
+ this.forget(handle.id, same);
231
+ }
232
+ const timer = setTimeout(() => {
233
+ const waiting = (this.pending.get(handle.id) ?? []).find(
234
+ (entry) => entry.holder === holder && entry.frame === frame,
235
+ );
236
+ if (waiting !== undefined) this.forget(handle.id, waiting);
237
+ }, this.deps.claimWaitMs);
238
+ // Never what keeps a process alive: a store shutting down stops the role anyway.
239
+ timer.unref();
240
+ const kept = [
241
+ ...(this.pending.get(handle.id) ?? []),
242
+ { handle, frame, holder, timer },
243
+ ].sort(rankedFirst);
244
+ this.pending.set(handle.id, kept);
245
+ for (const extra of kept.slice(PENDING_CLAIMS_PER_SENDER)) {
246
+ this.forget(handle.id, extra);
247
+ }
248
+ }
249
+
250
+ /** Removes one waiting claim and its timer. */
251
+ private forget(sender: string, entry: PendingClaim): void {
252
+ clearTimeout(entry.timer);
253
+ const rest = (this.pending.get(sender) ?? []).filter(
254
+ (other) => other !== entry,
255
+ );
256
+ if (rest.length === 0) this.pending.delete(sender);
257
+ else this.pending.set(sender, rest);
258
+ }
259
+
260
+ /** Takes every waiting claim that names `holder`, best first, except those whose sender no longer has a machine-local session here. */
261
+ private takePendingFor(holder: string): PendingClaim[] {
262
+ const senders = this.deps.requireTransport().electionPeerIds();
263
+ const taken: PendingClaim[] = [];
264
+ for (const [sender, entries] of [...this.pending]) {
265
+ for (const entry of entries) {
266
+ if (entry.holder !== holder) continue;
267
+ this.forget(sender, entry);
268
+ if (senders.has(sender)) taken.push(entry);
269
+ }
270
+ }
271
+ return taken.sort(rankedFirst);
272
+ }
273
+
274
+ private dropAllPending(): void {
275
+ for (const [sender, entries] of [...this.pending]) {
276
+ for (const entry of entries) this.forget(sender, entry);
277
+ }
278
+ }
279
+
161
280
  private isLowestSurvivor(departedPeerId: string): boolean {
162
281
  const selfId = this.deps.getPeerId();
163
282
  const self = deviceIdFromHex(selfId);