agent-comms 9.1.2 → 10.0.0

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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +12 -0
  4. package/dist/bridges/user/web/bridge-port-range.d.ts +9 -3
  5. package/dist/bridges/user/web/bridge-port-range.d.ts.map +1 -1
  6. package/dist/bridges/user/web/bridge-port-range.js +17 -4
  7. package/dist/bridges/user/web/bridge-port-range.js.map +1 -1
  8. package/dist/bridges/user/web/dist/bundle.js +1 -1
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/pairing-handshake.d.ts +17 -0
  12. package/dist/bridges/user/web/pairing-handshake.d.ts.map +1 -0
  13. package/dist/bridges/user/web/pairing-handshake.js +121 -0
  14. package/dist/bridges/user/web/pairing-handshake.js.map +1 -0
  15. package/dist/bridges/user/web/pairing-protocol.d.ts +64 -0
  16. package/dist/bridges/user/web/pairing-protocol.d.ts.map +1 -0
  17. package/dist/bridges/user/web/pairing-protocol.js +65 -0
  18. package/dist/bridges/user/web/pairing-protocol.js.map +1 -0
  19. package/dist/bridges/user/web/server.d.ts +6 -0
  20. package/dist/bridges/user/web/server.d.ts.map +1 -1
  21. package/dist/bridges/user/web/server.js +91 -60
  22. package/dist/bridges/user/web/server.js.map +1 -1
  23. package/dist/bridges/user/web/web-access.d.ts +9 -1
  24. package/dist/bridges/user/web/web-access.d.ts.map +1 -1
  25. package/dist/bridges/user/web/web-access.js +32 -7
  26. package/dist/bridges/user/web/web-access.js.map +1 -1
  27. package/dist/bridges/user/web/web-listen.d.ts +11 -0
  28. package/dist/bridges/user/web/web-listen.d.ts.map +1 -0
  29. package/dist/bridges/user/web/web-listen.js +53 -0
  30. package/dist/bridges/user/web/web-listen.js.map +1 -0
  31. package/dist/bridges/user/web/web-pairing-exchange.d.ts +68 -0
  32. package/dist/bridges/user/web/web-pairing-exchange.d.ts.map +1 -0
  33. package/dist/bridges/user/web/web-pairing-exchange.js +201 -0
  34. package/dist/bridges/user/web/web-pairing-exchange.js.map +1 -0
  35. package/dist/bridges/user/web/web-pairing.d.ts +58 -0
  36. package/dist/bridges/user/web/web-pairing.d.ts.map +1 -0
  37. package/dist/bridges/user/web/web-pairing.js +215 -0
  38. package/dist/bridges/user/web/web-pairing.js.map +1 -0
  39. package/dist/cli-main.d.ts.map +1 -1
  40. package/dist/cli-main.js +12 -1
  41. package/dist/cli-main.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/bridges/user/web/bridge-port-range.ts +24 -4
  44. package/src/bridges/user/web/dist/bundle.js +1 -1
  45. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  46. package/src/bridges/user/web/dist/sw.js +1 -1
  47. package/src/bridges/user/web/pairing-handshake.ts +170 -0
  48. package/src/bridges/user/web/pairing-protocol.ts +122 -0
  49. package/src/bridges/user/web/server.ts +137 -69
  50. package/src/bridges/user/web/web-access.ts +40 -6
  51. package/src/bridges/user/web/web-listen.ts +68 -0
  52. package/src/bridges/user/web/web-pairing-exchange.ts +292 -0
  53. package/src/bridges/user/web/web-pairing.ts +273 -0
  54. package/src/cli-main.ts +14 -1
@@ -1 +1 @@
1
- (function(){var e=`agent-comms-v2`,t=[{"revision":"fa356ca4b2a4b6ec4a929516b593a3e4","url":"mesh-worker.js"},{"revision":"053013aa131ee0e62e4492d27f4beb90","url":"index.html"},{"revision":"acb589210914ac93762291f24b3f6492","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))});var n=`agent-comms-`;self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t.startsWith(n)&&t!==e).map(async e=>self.caches.delete(e)))))});var r=200,i=300;function a(e,t){return e.method===`GET`&&t.status>=r&&t.status<i}function o(e){return e.type===`basic`&&e.status>=r&&e.status<i&&(e.headers.get(`content-type`)??``).startsWith(`text/html`)}var s=500,c=new URL(`index.html`,self.location.href).href;self.addEventListener(`fetch`,t=>{let n=new URL(t.request.url);if(t.request.mode===`navigate`){t.respondWith(fetch(t.request,{cache:`no-cache`}).then(n=>{if(t.request.method===`GET`&&o(n)){let t=n.clone();self.caches.open(e).then(async e=>e.put(c,t))}return n}).then(async t=>t.status<s?t:await(await self.caches.open(e)).match(c)??t).catch(async()=>self.caches.open(e).then(async e=>e.match(c)).then(e=>e??new Response(`Offline`,{status:503}))));return}if(n.pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
1
+ (function(){var e=`agent-comms-v2`,t=[{"revision":"a59c45daf71f1675523250b8117860fd","url":"mesh-worker.js"},{"revision":"053013aa131ee0e62e4492d27f4beb90","url":"index.html"},{"revision":"7b6bc3ecb738c654599a1ee5336ed192","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))});var n=`agent-comms-`;self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t.startsWith(n)&&t!==e).map(async e=>self.caches.delete(e)))))});var r=200,i=300;function a(e,t){return e.method===`GET`&&t.status>=r&&t.status<i}function o(e){return e.type===`basic`&&e.status>=r&&e.status<i&&(e.headers.get(`content-type`)??``).startsWith(`text/html`)}var s=500,c=new URL(`index.html`,self.location.href).href;self.addEventListener(`fetch`,t=>{let n=new URL(t.request.url);if(t.request.mode===`navigate`){t.respondWith(fetch(t.request,{cache:`no-cache`}).then(n=>{if(t.request.method===`GET`&&o(n)){let t=n.clone();self.caches.open(e).then(async e=>e.put(c,t))}return n}).then(async t=>t.status<s?t:await(await self.caches.open(e)).match(c)??t).catch(async()=>self.caches.open(e).then(async e=>e.match(c)).then(e=>e??new Response(`Offline`,{status:503}))));return}if(n.pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The dashboard's half of the proof exchange with a bridge (see web-pairing.ts for the design), run in the browser and using only the `WebSocket` and Web Crypto globals the browser and Node both have, so it imports nothing from either: before the dashboard sends a listener anything, it asks it to prove it holds the secret this browser was paired with, checks that proof, and only then proves it holds the secret too. The secret is the key of both proofs and is never sent.
3
+ */
4
+
5
+ import {
6
+ HELLO_PATH,
7
+ HEX_32_PATTERN,
8
+ NONCE_BYTES,
9
+ isHelloReply,
10
+ isHelloTicket,
11
+ parseHelloMessage,
12
+ proofInput,
13
+ type HelloAuth,
14
+ type HelloRequest,
15
+ type ProofTranscript,
16
+ } from "./pairing-protocol.js";
17
+
18
+ /** How long the whole exchange may take before the port counts as no bridge. A bridge answers three small frames on loopback well inside this. */
19
+ export const HANDSHAKE_TIMEOUT_MS = 2000;
20
+
21
+ const HEX_RADIX = 16;
22
+ const HEX_DIGITS_PER_BYTE = 2;
23
+
24
+ function toHex(bytes: Uint8Array): string {
25
+ return Array.from(bytes, (byte) =>
26
+ byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0"),
27
+ ).join("");
28
+ }
29
+
30
+ function fromHex(hex: string): Uint8Array<ArrayBuffer> {
31
+ const bytes = new Uint8Array(hex.length / HEX_DIGITS_PER_BYTE);
32
+ for (let index = 0; index < bytes.length; index++) {
33
+ bytes[index] = Number.parseInt(
34
+ hex.slice(index * HEX_DIGITS_PER_BYTE, (index + 1) * HEX_DIGITS_PER_BYTE),
35
+ HEX_RADIX,
36
+ );
37
+ }
38
+ return bytes;
39
+ }
40
+
41
+ async function hmacKey(secret: string): Promise<CryptoKey> {
42
+ return crypto.subtle.importKey(
43
+ "raw",
44
+ fromHex(secret),
45
+ { name: "HMAC", hash: "SHA-256" },
46
+ false,
47
+ ["sign", "verify"],
48
+ );
49
+ }
50
+
51
+ /** The proof a holder of `secret` gives for this exchange, as hex. */
52
+ export async function proofFor(
53
+ secret: string,
54
+ transcript: Readonly<ProofTranscript>,
55
+ ): Promise<string> {
56
+ const signature = await crypto.subtle.sign(
57
+ "HMAC",
58
+ await hmacKey(secret),
59
+ proofInput(transcript),
60
+ );
61
+ return toHex(new Uint8Array(signature));
62
+ }
63
+
64
+ /** Whether `presented` is the proof for this exchange under `secret`. The comparison is the platform's own `verify`, which is constant time. */
65
+ export async function proofChecks(
66
+ presented: string,
67
+ secret: string,
68
+ transcript: Readonly<ProofTranscript>,
69
+ ): Promise<boolean> {
70
+ if (!HEX_32_PATTERN.test(presented)) return false;
71
+ return crypto.subtle.verify(
72
+ "HMAC",
73
+ await hmacKey(secret),
74
+ fromHex(presented),
75
+ proofInput(transcript),
76
+ );
77
+ }
78
+
79
+ /** The address of the exchange's socket on `port`. */
80
+ export function helloUrl(port: number): string {
81
+ return `ws://127.0.0.1:${String(port)}${HELLO_PATH}`;
82
+ }
83
+
84
+ function newNonce(): string {
85
+ return toHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
86
+ }
87
+
88
+ /**
89
+ * Runs the exchange with whatever listens on `port` and resolves with the single-use ticket for one mesh socket, or undefined when the listener is not a bridge that holds `secret`, or does not answer in time. What the listener is sent before it has proved itself is one nonce.
90
+ */
91
+ export async function acquireTicket(
92
+ port: number,
93
+ secret: string,
94
+ timeoutMs: number = HANDSHAKE_TIMEOUT_MS,
95
+ ): Promise<string | undefined> {
96
+ return new Promise((resolve) => {
97
+ let socket: WebSocket;
98
+ try {
99
+ socket = new WebSocket(helloUrl(port));
100
+ } catch {
101
+ resolve(undefined);
102
+ return;
103
+ }
104
+ const clientNonce = newNonce();
105
+ let bridgeNonce: string | undefined;
106
+ let settled = false;
107
+ const timer: { handle?: ReturnType<typeof setTimeout> } = {};
108
+ const settle = (ticket: string | undefined): void => {
109
+ if (settled) return;
110
+ settled = true;
111
+ clearTimeout(timer.handle);
112
+ socket.close();
113
+ resolve(ticket);
114
+ };
115
+ timer.handle = setTimeout(() => {
116
+ settle(undefined);
117
+ }, timeoutMs);
118
+ socket.addEventListener("error", () => {
119
+ settle(undefined);
120
+ });
121
+ socket.addEventListener("close", () => {
122
+ settle(undefined);
123
+ });
124
+ socket.addEventListener("open", () => {
125
+ const request: HelloRequest = { type: "hello", nonce: clientNonce };
126
+ socket.send(JSON.stringify(request));
127
+ });
128
+ socket.addEventListener("message", (event: MessageEvent) => {
129
+ if (typeof event.data !== "string") {
130
+ settle(undefined);
131
+ return;
132
+ }
133
+ const message = parseHelloMessage(event.data);
134
+ if (bridgeNonce === undefined) {
135
+ if (!isHelloReply(message)) {
136
+ settle(undefined);
137
+ return;
138
+ }
139
+ bridgeNonce = message.nonce;
140
+ void proofChecks(message.proof, secret, {
141
+ role: "bridge",
142
+ clientNonce,
143
+ bridgeNonce: message.nonce,
144
+ port,
145
+ })
146
+ .then(async (genuine) => {
147
+ if (!genuine) {
148
+ settle(undefined);
149
+ return;
150
+ }
151
+ const auth: HelloAuth = {
152
+ type: "auth",
153
+ proof: await proofFor(secret, {
154
+ role: "client",
155
+ clientNonce,
156
+ bridgeNonce: message.nonce,
157
+ port,
158
+ }),
159
+ };
160
+ socket.send(JSON.stringify(auth));
161
+ })
162
+ .catch(() => {
163
+ settle(undefined);
164
+ });
165
+ return;
166
+ }
167
+ settle(isHelloTicket(message) ? message.ticket : undefined);
168
+ });
169
+ });
170
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * What the hosted dashboard and the bridge agree on for pairing (web-pairing.ts describes the design): the fragment parameter the pairing page hands the secret over in, the paths and subprotocols of the proof exchange, the shape of its messages, and the bytes each side keys its proof over. Shared by the Node server and the browser frontend, so it imports nothing from either.
3
+ *
4
+ * The secret never crosses a socket. Before the dashboard sends anything to a listener it asks the listener to prove it holds the secret, and only then proves it holds it too (see `proofInput`); the bridge answers with a single-use ticket that admits one mesh socket.
5
+ */
6
+
7
+ /** The name of the fragment parameter the pairing page opens the dashboard with. */
8
+ export const PAIRING_FRAGMENT_PARAM = "pair";
9
+
10
+ /** The path of the mesh socket the dashboard's own traffic runs over. */
11
+ export const MESH_PATH = "/ws/mesh";
12
+
13
+ /** The path of the socket the proof exchange runs over. It carries nothing but the exchange, and a page of the hosted origin may open it without a ticket. */
14
+ export const HELLO_PATH = "/ws/hello";
15
+
16
+ /** The subprotocol a server selects for every mesh upgrade that offers the dashboard's protocols, so the offered ticket is never echoed back. */
17
+ export const MESH_SUBPROTOCOL = "agent-comms";
18
+
19
+ /** The prefix of the subprotocol that carries a ticket on a mesh upgrade, because the browser's WebSocket API cannot set headers and a query string would put the ticket in logs and history. */
20
+ export const TICKET_SUBPROTOCOL_PREFIX = "agent-comms-ticket.";
21
+
22
+ /** What a secret looks like: exactly 32 bytes in lower-case hex. */
23
+ export const PAIRING_SECRET_PATTERN = /^[0-9a-f]{64}$/;
24
+
25
+ /** What a nonce, a proof and a ticket look like: 32 bytes in lower-case hex. */
26
+ export const HEX_32_PATTERN = /^[0-9a-f]{64}$/;
27
+
28
+ /** Random bytes in a nonce, and in a ticket. */
29
+ export const NONCE_BYTES = 32;
30
+
31
+ /** The role label a proof is keyed over: the bridge proving it holds the secret, and the dashboard proving it. Different labels and a different order of the two nonces keep one proof from standing in for the other. */
32
+ export type ProofRole = "bridge" | "client";
33
+
34
+ /** The dashboard's first message: its fresh nonce. */
35
+ export interface HelloRequest {
36
+ type: "hello";
37
+ nonce: string;
38
+ }
39
+
40
+ /** The bridge's answer: its own fresh nonce and its proof over both. */
41
+ export interface HelloReply {
42
+ type: "hello-ok";
43
+ nonce: string;
44
+ proof: string;
45
+ }
46
+
47
+ /** The dashboard's proof, sent only after the bridge's checked out. */
48
+ export interface HelloAuth {
49
+ type: "auth";
50
+ proof: string;
51
+ }
52
+
53
+ /** The bridge's last message: the single-use ticket for one mesh socket. */
54
+ export interface HelloTicket {
55
+ type: "ticket";
56
+ ticket: string;
57
+ }
58
+
59
+ function isRecord(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value);
61
+ }
62
+
63
+ function hex32(value: unknown): value is string {
64
+ return typeof value === "string" && HEX_32_PATTERN.test(value);
65
+ }
66
+
67
+ /** Reads one JSON message of the exchange, or undefined for text that is not JSON. The caller narrows the result with the guards below. */
68
+ export function parseHelloMessage(text: string): unknown {
69
+ try {
70
+ return JSON.parse(text);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export function isHelloRequest(value: unknown): value is HelloRequest {
77
+ return isRecord(value) && value.type === "hello" && hex32(value.nonce);
78
+ }
79
+
80
+ export function isHelloReply(value: unknown): value is HelloReply {
81
+ return (
82
+ isRecord(value) &&
83
+ value.type === "hello-ok" &&
84
+ hex32(value.nonce) &&
85
+ hex32(value.proof)
86
+ );
87
+ }
88
+
89
+ export function isHelloAuth(value: unknown): value is HelloAuth {
90
+ return isRecord(value) && value.type === "auth" && hex32(value.proof);
91
+ }
92
+
93
+ export function isHelloTicket(value: unknown): value is HelloTicket {
94
+ return isRecord(value) && value.type === "ticket" && hex32(value.ticket);
95
+ }
96
+
97
+ /** What a proof is made over: whose proof it is, the two nonces in the order the exchange sent them (the dashboard's, then the bridge's) and the port the dashboard dialled. */
98
+ export interface ProofTranscript {
99
+ role: ProofRole;
100
+ clientNonce: string;
101
+ bridgeNonce: string;
102
+ port: number;
103
+ }
104
+
105
+ /**
106
+ * The bytes a proof is keyed over, with the secret as the key. A proof for one port is not one for another, so a listener that relays the dashboard's challenge to the real bridge on a different port cannot pass the answer off as its own.
107
+ */
108
+ export function proofInput(
109
+ transcript: Readonly<ProofTranscript>,
110
+ ): Uint8Array<ArrayBuffer> {
111
+ const { role, clientNonce, bridgeNonce, port } = transcript;
112
+ return new TextEncoder().encode(
113
+ [role, clientNonce, bridgeNonce, String(port)].join("\0"),
114
+ );
115
+ }
116
+
117
+ /** The subprotocols the dashboard offers when it opens a mesh socket: the fixed one, and the one carrying its ticket when it has one. */
118
+ export function meshSocketProtocols(ticket: string | undefined): string[] {
119
+ return ticket === undefined
120
+ ? [MESH_SUBPROTOCOL]
121
+ : [MESH_SUBPROTOCOL, `${TICKET_SUBPROTOCOL_PREFIX}${ticket}`];
122
+ }
@@ -8,7 +8,6 @@
8
8
  * When AGENT_COMMS_WEB_CONSOLE_DIST points at a built wire-mesh web-console (see web-console-static.ts), GET /web-console/* also serves that as an alternate, generic reference-client UI alongside the frontend above.
9
9
  */
10
10
 
11
- import { once } from "node:events";
12
11
  import * as http from "node:http";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import { WebSocketServer } from "ws";
@@ -50,6 +49,8 @@ import {
50
49
  ACCESS_QUERY_PARAM,
51
50
  accessUrls,
52
51
  authorise,
52
+ isHostedDashboardOrigin,
53
+ isOwnClient,
53
54
  generateAccessToken,
54
55
  localWebUrl,
55
56
  ownInterfaceAddresses,
@@ -59,6 +60,8 @@ import {
59
60
  type AccessPolicy,
60
61
  } from "./web-access.js";
61
62
  import { bridgePortRange } from "./bridge-port-range.js";
63
+ import { listen } from "./web-listen.js";
64
+ import { HOSTED_DASHBOARD_ORIGIN } from "./hosted-dashboard.js";
62
65
  import { startWebBeacon } from "./web-beacon.js";
63
66
  import {
64
67
  REQUEST_TIMEOUT_MS,
@@ -67,6 +70,24 @@ import {
67
70
  readBoundedBody,
68
71
  serveRoute,
69
72
  } from "./request-handling.js";
73
+ import {
74
+ PAIRING_PAGE_HEADERS,
75
+ PAIRING_PAGE_PATH,
76
+ openPairingSecret,
77
+ watchPairingSecret,
78
+ pairingPageHtml,
79
+ selectMeshSubprotocol,
80
+ type PairingSecretStore,
81
+ } from "./web-pairing.js";
82
+ import {
83
+ MAX_HELLO_MESSAGE_BYTES,
84
+ MAX_HELLO_SOCKETS,
85
+ ROTATION_SWEEP_MS,
86
+ createHostedSocketGuard,
87
+ createTicketBook,
88
+ serveHello,
89
+ } from "./web-pairing-exchange.js";
90
+ import { HELLO_PATH, MESH_PATH } from "./pairing-protocol.js";
70
91
  import {
71
92
  createFrontendAssetLoader,
72
93
  FrontendNotBuiltError,
@@ -76,6 +97,7 @@ import {
76
97
  const HTTP_OK = 200;
77
98
  const HTTP_BAD_REQUEST = 400;
78
99
  const HTTP_UNAUTHORIZED = 401;
100
+ const HTTP_FORBIDDEN = 403;
79
101
  const HTTP_NOT_FOUND = 404;
80
102
  const HTTP_INTERNAL_SERVER_ERROR = 500;
81
103
  const HTTP_NOT_IMPLEMENTED = 501;
@@ -168,68 +190,6 @@ export async function tryStartBridgeWebServer(
168
190
  });
169
191
  }
170
192
 
171
- /** Whether a listen failure means the port is already taken. */
172
- function isAddressInUse(error: unknown): boolean {
173
- return (
174
- typeof error === "object" &&
175
- error !== null &&
176
- "code" in error &&
177
- error.code === "EADDRINUSE"
178
- );
179
- }
180
-
181
- /** Binds `server` to `port` on `host`; true once listening, false when the port is taken, and rejects on any other failure. */
182
- async function tryListen(
183
- server: http.Server,
184
- port: number,
185
- host: string,
186
- ): Promise<boolean> {
187
- // once() rejects when the server emits "error" before "listening", and removes both of its listeners either way.
188
- const listening = once(server, "listening");
189
- server.listen(port, host);
190
- try {
191
- await listening;
192
- return true;
193
- } catch (error) {
194
- if (isAddressInUse(error)) return false;
195
- throw error;
196
- }
197
- }
198
-
199
- /**
200
- * Binds `server` to the first of `ports` that is free, in order, and returns the port it holds.
201
- *
202
- * Each port is bound for real, so a port another process takes between two attempts is simply skipped, where a test-then-bind would hand two processes the same port. Only "already in use" moves on to the next port: any other bind error rejects with that error. When `fallBackToAnyPort` is set and every port is taken the operating system picks one; when nothing could be bound the error names every port tried.
203
- */
204
- async function listen(
205
- server: http.Server,
206
- host: string,
207
- ports: readonly number[],
208
- fallBackToAnyPort: boolean,
209
- ): Promise<number> {
210
- for (const port of ports) {
211
- if (await tryListen(server, port, host)) return boundPortOf(server);
212
- }
213
- if (fallBackToAnyPort && (await tryListen(server, 0, host))) {
214
- return boundPortOf(server);
215
- }
216
- const tried = fallBackToAnyPort
217
- ? [...ports.map(String), "an operating-system port"]
218
- : ports.map(String);
219
- throw new Error(
220
- `Agent Comms web UI: every port tried is already in use on ${host}: ${tried.join(", ")}`,
221
- );
222
- }
223
-
224
- /** The port a listening server holds. */
225
- function boundPortOf(server: http.Server): number {
226
- const addr = server.address();
227
- if (typeof addr !== "object" || addr === null) {
228
- throw new Error("Agent Comms web UI: the listening server has no address");
229
- }
230
- return addr.port;
231
- }
232
-
233
193
  /**
234
194
  * Create the web server and resolve once it is listening.
235
195
  *
@@ -251,6 +211,12 @@ export async function createWebServer(options?: {
251
211
  localClientAddresses?: (() => ReadonlySet<string>) | undefined;
252
212
  /** The UDP port the LAN beacon advertises on when the bind is beyond loopback. Defaults to the first-contact port; tests pass an OS-assigned free port. */
253
213
  beaconPort?: number | undefined;
214
+ /** The directory the pairing secret is kept in. Defaults to the bridge's data directory; tests pass one of their own. */
215
+ pairingDir?: string | undefined;
216
+ /** Whether to watch the pairing secret file and act on a rotation the moment it is reported. Defaults to true; a test turns it off to see what the periodic sweep and the next message do alone. */
217
+ watchPairingFile?: boolean | undefined;
218
+ /** How often sockets admitted under a replaced pairing secret are looked for and closed. Defaults to ROTATION_SWEEP_MS; a test sets it long to see that a message alone ends such a socket. */
219
+ pairingSweepMs?: number | undefined;
254
220
  existingController?: ChatController | undefined;
255
221
  coordinatorPort?: number | undefined;
256
222
  /** Overrides the first-contact UDP port a fresh controller's store runs presence on (ChatController's own firstContactPort); ignored when existingController is supplied. */
@@ -268,6 +234,9 @@ export async function createWebServer(options?: {
268
234
  host: requestedHost,
269
235
  localClientAddresses = ownInterfaceAddresses,
270
236
  beaconPort,
237
+ pairingDir,
238
+ watchPairingFile = true,
239
+ pairingSweepMs = ROTATION_SWEEP_MS,
271
240
  existingController,
272
241
  coordinatorPort,
273
242
  firstContactPort,
@@ -294,11 +263,23 @@ export async function createWebServer(options?: {
294
263
  : undefined;
295
264
 
296
265
  // The port is only known once the server listens (port 0 asks the OS), so the policy's port is filled in once the server is bound, before any request can arrive.
266
+ const pairing = openPairingSecret({
267
+ dir: pairingDir,
268
+ onReplaced: (file) => {
269
+ process.stderr.write(
270
+ `agent-comms: ${file} was missing or unusable and has been replaced, so browsers paired with the hosted dashboard must pair again\n`,
271
+ );
272
+ },
273
+ });
274
+ const tickets = createTicketBook(pairing);
275
+ const hostedSockets = createHostedSocketGuard(pairing);
297
276
  const accessPolicy: AccessPolicy = {
298
277
  token: accessToken,
299
278
  bindHost: host,
300
279
  port: port ?? 0,
301
280
  localClientAddresses,
281
+ // Tickets are only accepted under the secret in force, so `agent-comms pairing rotate` refuses every ticket issued before it without a restart.
282
+ tickets,
302
283
  };
303
284
 
304
285
  const server = http.createServer(
@@ -314,7 +295,11 @@ export async function createWebServer(options?: {
314
295
  res.setHeader("X-Content-Type-Options", "nosniff");
315
296
  try {
316
297
  if (!admitRequest(accessPolicy, req, res)) return;
317
- handleRequest(req, res, controller, webConsoleDist);
298
+ handleRequest(req, res, controller, {
299
+ pairing,
300
+ isOwnClient: isOwnClient(accessPolicy, req.socket.remoteAddress),
301
+ webConsoleDist,
302
+ });
318
303
  } catch (err) {
319
304
  // A request the synchronous routing cannot handle is answered, not thrown: an exception out of this listener would end the process.
320
305
  process.stderr.write(
@@ -353,8 +338,40 @@ export async function createWebServer(options?: {
353
338
  const wss = new WebSocketServer({
354
339
  noServer: true,
355
340
  maxPayload: MAX_MESSAGE_BYTES,
341
+ // The dashboard offers the mesh subprotocol beside its ticket; selecting only the fixed name keeps the ticket from being echoed.
342
+ handleProtocols: selectMeshSubprotocol,
343
+ });
344
+ // The proof exchange's own socket: tiny messages only, and a handful at a time.
345
+ const helloWss = new WebSocketServer({
346
+ noServer: true,
347
+ maxPayload: MAX_HELLO_MESSAGE_BYTES,
356
348
  });
357
349
  const rpcHandler = new RPCHandler(meshRouter);
350
+ // A socket admitted under a secret that has since been replaced is ended, also when it is idle.
351
+ const rotationSweep = setInterval(() => {
352
+ hostedSockets.sweep();
353
+ }, pairingSweepMs);
354
+ rotationSweep.unref();
355
+ // The sweep is the backstop. A rotation is acted on the moment the secret file changes, so a revoked socket does not stay open for the length of a poll.
356
+ // Opening the secret creates its directory, which the watch needs to exist.
357
+ pairing.current();
358
+ const stopWatching = (
359
+ watchPairingFile ? watchPairingSecret : () => () => undefined
360
+ )({
361
+ dir: pairingDir,
362
+ onChange: () => {
363
+ hostedSockets.sweep();
364
+ },
365
+ onError: (error) => {
366
+ process.stderr.write(
367
+ `agent-comms: cannot watch the pairing secret, so a rotation ends hosted sockets at the next sweep: ${error.message}\n`,
368
+ );
369
+ },
370
+ });
371
+ server.once("close", () => {
372
+ clearInterval(rotationSweep);
373
+ stopWatching();
374
+ });
358
375
 
359
376
  server.on("upgrade", (req, socket, head) => {
360
377
  // A socket error before or after the upgrade (a peer that resets, say) is that connection's alone.
@@ -369,22 +386,35 @@ export async function createWebServer(options?: {
369
386
  socket.destroy();
370
387
  return;
371
388
  }
372
- if (req.url === "/ws/mesh") {
389
+ if (req.url === MESH_PATH) {
373
390
  wss.handleUpgrade(req, socket, head, (ws) => {
374
391
  wss.emit("connection", ws, req);
375
392
  });
393
+ } else if (
394
+ req.url === HELLO_PATH &&
395
+ helloWss.clients.size < MAX_HELLO_SOCKETS
396
+ ) {
397
+ helloWss.handleUpgrade(req, socket, head, (ws) => {
398
+ serveHello(ws, {
399
+ secret: pairing,
400
+ tickets,
401
+ port: () => accessPolicy.port,
402
+ });
403
+ });
376
404
  } else {
377
405
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
378
406
  socket.destroy();
379
407
  }
380
408
  });
381
409
 
382
- wss.on("connection", (ws) => {
410
+ wss.on("connection", (ws, req) => {
383
411
  // 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
412
  ws.on("error", reportSocketError);
413
+ if (isHostedDashboardOrigin(req.headers.origin)) hostedSockets.track(ws);
385
414
  // 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().
386
415
  const context = { controller, publisher };
387
416
  ws.on("message", (data, isBinary) => {
417
+ if (!hostedSockets.check(ws)) return;
388
418
  // "ws" types incoming data as Buffer | ArrayBuffer | Buffer[] regardless of frame type; normalise to a real Buffer first so both branches below have a type they can trust rather than calling .toString()/Uint8Array.from() on a union that could be an ArrayBuffer.
389
419
  const raw = Array.isArray(data) ? Buffer.concat(data) : data;
390
420
  const bytes = raw instanceof ArrayBuffer ? Buffer.from(raw) : raw;
@@ -399,6 +429,9 @@ export async function createWebServer(options?: {
399
429
 
400
430
  // stderr, not stdout: a stdio MCP bridge owns stdout for JSON-RPC, so any other line there is protocol noise to a strictly framed client.
401
431
  console.error(`Agent Comms web UI: ${localWebUrl(host, boundPort)}`);
432
+ console.error(
433
+ `To pair the hosted dashboard with this bridge, open ${localWebUrl(host, boundPort)}${PAIRING_PAGE_PATH} in the browser you use`,
434
+ );
402
435
  if (accessToken !== undefined) {
403
436
  announceLanAccess(
404
437
  server,
@@ -526,11 +559,41 @@ export async function runWeb(userName: string, port?: number): Promise<void> {
526
559
  // HTTP handler
527
560
  // ---------------------------------------------------------------------------
528
561
 
562
+ /** What a request handler knows about the request beyond its parts: the pairing secret store, and whether the client is on this machine. */
563
+ interface RequestContext {
564
+ pairing: PairingSecretStore;
565
+ isOwnClient: boolean;
566
+ webConsoleDist: string | undefined;
567
+ }
568
+
569
+ /** The `Sec-Fetch-Site` values of a request a person started from the address bar or a bookmark (`none`), or that this server's own page made. A link or a script on another site sends `cross-site` or `same-site`. */
570
+ const PERSON_INITIATED_FETCH_SITES = new Set(["none", "same-origin"]);
571
+
572
+ /** Serves the pairing page to a person at this machine: a client on this machine, no Origin of another site and no cross-site fetch metadata. Anything else is a 403, answered without the secret. */
573
+ function servePairingPage(
574
+ req: http.IncomingMessage,
575
+ res: http.ServerResponse,
576
+ context: Readonly<RequestContext>,
577
+ ): void {
578
+ const site = req.headers["sec-fetch-site"];
579
+ const fromAnotherSite =
580
+ typeof site === "string" && !PERSON_INITIATED_FETCH_SITES.has(site);
581
+ // A page of the hosted dashboard, paired or not, is never the person this page is for.
582
+ const fromHostedDashboard = req.headers.origin === HOSTED_DASHBOARD_ORIGIN;
583
+ if (!context.isOwnClient || fromAnotherSite || fromHostedDashboard) {
584
+ res.writeHead(HTTP_FORBIDDEN, { "Content-Type": "text/plain" });
585
+ res.end("Forbidden");
586
+ return;
587
+ }
588
+ res.writeHead(HTTP_OK, PAIRING_PAGE_HEADERS);
589
+ res.end(pairingPageHtml(context.pairing.current()));
590
+ }
591
+
529
592
  function handleRequest(
530
593
  req: http.IncomingMessage,
531
594
  res: http.ServerResponse,
532
595
  controller: ChatController,
533
- webConsoleDist: string | undefined,
596
+ context: Readonly<RequestContext>,
534
597
  ): void {
535
598
  let url: URL;
536
599
  try {
@@ -543,13 +606,18 @@ function handleRequest(
543
606
 
544
607
  // wire-mesh's web-console — an alternate, generic reference-client UI, served only when AGENT_COMMS_WEB_CONSOLE_DIST resolves to a real build.
545
608
  if (
546
- webConsoleDist !== undefined &&
609
+ context.webConsoleDist !== undefined &&
547
610
  req.method === "GET" &&
548
- serveWebConsole(webConsoleDist, url.pathname, res)
611
+ serveWebConsole(context.webConsoleDist, url.pathname, res)
549
612
  ) {
550
613
  return;
551
614
  }
552
615
 
616
+ if (url.pathname === PAIRING_PAGE_PATH && req.method === "GET") {
617
+ servePairingPage(req, res, context);
618
+ return;
619
+ }
620
+
553
621
  // Frontend HTML -- also served at the literal /index.html path (not just
554
622
  // /) because vite-plugin-pwa's injectManifest precache list references
555
623
  // the build output's own filename, index.html, and precaches it by