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.
- 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/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/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
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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 (
|
|
43
|
-
|
|
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. */
|