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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bridges/user/web/request-handling.d.ts +26 -0
- package/dist/bridges/user/web/request-handling.d.ts.map +1 -0
- package/dist/bridges/user/web/request-handling.js +92 -0
- package/dist/bridges/user/web/request-handling.js.map +1 -0
- package/dist/bridges/user/web/server.d.ts +4 -0
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +88 -53
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/web-access.d.ts.map +1 -1
- package/dist/bridges/user/web/web-access.js +14 -1
- package/dist/bridges/user/web/web-access.js.map +1 -1
- package/dist/bridges/user/web/web-assets.d.ts +2 -1
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -1
- package/dist/bridges/user/web/web-assets.js +2 -2
- package/dist/bridges/user/web/web-assets.js.map +1 -1
- package/dist/bridges/user/web/web-console-static.d.ts.map +1 -1
- package/dist/bridges/user/web/web-console-static.js +34 -22
- package/dist/bridges/user/web/web-console-static.js.map +1 -1
- package/dist/core/coordinator-role.d.ts +15 -2
- package/dist/core/coordinator-role.d.ts.map +1 -1
- package/dist/core/coordinator-role.js +109 -11
- package/dist/core/coordinator-role.js.map +1 -1
- package/dist/core/detached-work.d.ts +14 -0
- package/dist/core/detached-work.d.ts.map +1 -0
- package/dist/core/detached-work.js +40 -0
- package/dist/core/detached-work.js.map +1 -0
- package/dist/core/election-sessions.d.ts.map +1 -1
- package/dist/core/election-sessions.js +6 -6
- package/dist/core/election-sessions.js.map +1 -1
- package/dist/core/hub-session.d.ts +10 -1
- package/dist/core/hub-session.d.ts.map +1 -1
- package/dist/core/hub-session.js +34 -32
- package/dist/core/hub-session.js.map +1 -1
- package/dist/core/message-limits.d.ts +14 -0
- package/dist/core/message-limits.d.ts.map +1 -0
- package/dist/core/message-limits.js +22 -0
- package/dist/core/message-limits.js.map +1 -0
- package/dist/core/peer-lifecycle.d.ts +2 -0
- package/dist/core/peer-lifecycle.d.ts.map +1 -1
- package/dist/core/peer-lifecycle.js +9 -4
- package/dist/core/peer-lifecycle.js.map +1 -1
- package/dist/core/pending-connection.d.ts +20 -1
- package/dist/core/pending-connection.d.ts.map +1 -1
- package/dist/core/pending-connection.js +27 -0
- package/dist/core/pending-connection.js.map +1 -1
- package/dist/core/relay-server.d.ts +15 -1
- package/dist/core/relay-server.d.ts.map +1 -1
- package/dist/core/relay-server.js +70 -16
- package/dist/core/relay-server.js.map +1 -1
- package/dist/core/room-router.d.ts.map +1 -1
- package/dist/core/room-router.js +31 -10
- package/dist/core/room-router.js.map +1 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +3 -0
- package/dist/core/tool.js.map +1 -1
- package/dist/core/transport-events.d.ts.map +1 -1
- package/dist/core/transport-events.js +9 -5
- package/dist/core/transport-events.js.map +1 -1
- package/dist/core/wire-mesh-transport.d.ts +2 -2
- package/dist/core/wire-mesh-transport.d.ts.map +1 -1
- package/dist/core/wire-mesh-transport.js +96 -115
- package/dist/core/wire-mesh-transport.js.map +1 -1
- package/package.json +1 -1
- package/src/bridges/user/web/request-handling.ts +118 -0
- package/src/bridges/user/web/server.ts +109 -54
- package/src/bridges/user/web/web-access.ts +14 -1
- package/src/bridges/user/web/web-assets.ts +7 -3
- package/src/bridges/user/web/web-console-static.ts +34 -26
- package/src/core/coordinator-role.ts +126 -7
- package/src/core/detached-work.ts +55 -0
- package/src/core/election-sessions.ts +8 -4
- package/src/core/hub-session.ts +63 -41
- package/src/core/message-limits.ts +29 -0
- package/src/core/peer-lifecycle.ts +10 -4
- package/src/core/pending-connection.ts +48 -1
- package/src/core/relay-server.ts +110 -23
- package/src/core/room-router.ts +36 -9
- package/src/core/tool.ts +3 -0
- package/src/core/transport-events.ts +9 -5
- 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
|
-
|
|
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(
|
|
286
|
-
|
|
287
|
-
|
|
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({
|
|
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
|
-
|
|
393
|
+
rpcHandler.message(ws, payload, { context }).catch(reportSocketError);
|
|
345
394
|
});
|
|
346
395
|
ws.on("close", () => {
|
|
347
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
const body = await
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
60
|
+
const icons = new Map<string, Buffer>();
|
|
60
61
|
for (const filename of ICON_FILENAMES) {
|
|
61
|
-
icons
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
".
|
|
20
|
-
".
|
|
21
|
-
".
|
|
22
|
-
".
|
|
23
|
-
".
|
|
24
|
-
".
|
|
25
|
-
".
|
|
26
|
-
".
|
|
27
|
-
".
|
|
28
|
-
".
|
|
29
|
-
".
|
|
30
|
-
".
|
|
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
|
|
72
|
-
|
|
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
|
|
94
|
+
CONTENT_TYPES.get(path.extname(servable)) ?? DEFAULT_CONTENT_TYPE;
|
|
87
95
|
res.writeHead(HTTP_OK, { "Content-Type": contentType });
|
|
88
|
-
res.end(fs.readFileSync(
|
|
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.
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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
|
-
|
|
123
|
-
|
|
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);
|