agent-comms 9.1.3 → 10.0.1
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 +12 -0
- package/dist/bridges/user/web/bridge-port-range.d.ts +9 -3
- package/dist/bridges/user/web/bridge-port-range.d.ts.map +1 -1
- package/dist/bridges/user/web/bridge-port-range.js +17 -4
- package/dist/bridges/user/web/bridge-port-range.js.map +1 -1
- package/dist/bridges/user/web/dist/bundle.js +1 -1
- package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
- package/dist/bridges/user/web/dist/sw.js +1 -1
- package/dist/bridges/user/web/pairing-handshake.d.ts +17 -0
- package/dist/bridges/user/web/pairing-handshake.d.ts.map +1 -0
- package/dist/bridges/user/web/pairing-handshake.js +121 -0
- package/dist/bridges/user/web/pairing-handshake.js.map +1 -0
- package/dist/bridges/user/web/pairing-protocol.d.ts +64 -0
- package/dist/bridges/user/web/pairing-protocol.d.ts.map +1 -0
- package/dist/bridges/user/web/pairing-protocol.js +65 -0
- package/dist/bridges/user/web/pairing-protocol.js.map +1 -0
- package/dist/bridges/user/web/server.d.ts +6 -0
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +91 -60
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/web-access.d.ts +9 -1
- package/dist/bridges/user/web/web-access.d.ts.map +1 -1
- package/dist/bridges/user/web/web-access.js +32 -7
- package/dist/bridges/user/web/web-access.js.map +1 -1
- package/dist/bridges/user/web/web-listen.d.ts +11 -0
- package/dist/bridges/user/web/web-listen.d.ts.map +1 -0
- package/dist/bridges/user/web/web-listen.js +53 -0
- package/dist/bridges/user/web/web-listen.js.map +1 -0
- package/dist/bridges/user/web/web-pairing-exchange.d.ts +68 -0
- package/dist/bridges/user/web/web-pairing-exchange.d.ts.map +1 -0
- package/dist/bridges/user/web/web-pairing-exchange.js +201 -0
- package/dist/bridges/user/web/web-pairing-exchange.js.map +1 -0
- package/dist/bridges/user/web/web-pairing.d.ts +58 -0
- package/dist/bridges/user/web/web-pairing.d.ts.map +1 -0
- package/dist/bridges/user/web/web-pairing.js +215 -0
- package/dist/bridges/user/web/web-pairing.js.map +1 -0
- package/dist/cli-main.d.ts.map +1 -1
- package/dist/cli-main.js +12 -1
- package/dist/cli-main.js.map +1 -1
- package/package.json +2 -2
- package/src/bridges/user/web/bridge-port-range.ts +24 -4
- package/src/bridges/user/web/dist/bundle.js +1 -1
- package/src/bridges/user/web/dist/mesh-worker.js +4 -4
- package/src/bridges/user/web/dist/sw.js +1 -1
- package/src/bridges/user/web/pairing-handshake.ts +170 -0
- package/src/bridges/user/web/pairing-protocol.ts +122 -0
- package/src/bridges/user/web/server.ts +137 -69
- package/src/bridges/user/web/web-access.ts +40 -6
- package/src/bridges/user/web/web-listen.ts +68 -0
- package/src/bridges/user/web/web-pairing-exchange.ts +292 -0
- package/src/bridges/user/web/web-pairing.ts +273 -0
- package/src/cli-main.ts +14 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(){var e=`agent-comms-v2`,t=[{"revision":"
|
|
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,
|
|
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 ===
|
|
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
|
-
|
|
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
|