agent-comms 9.1.3 → 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 +1 -1
  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,17 @@
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
+ import { type ProofTranscript } from "./pairing-protocol.js";
5
+ /** 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. */
6
+ export declare const HANDSHAKE_TIMEOUT_MS = 2000;
7
+ /** The proof a holder of `secret` gives for this exchange, as hex. */
8
+ export declare function proofFor(secret: string, transcript: Readonly<ProofTranscript>): Promise<string>;
9
+ /** Whether `presented` is the proof for this exchange under `secret`. The comparison is the platform's own `verify`, which is constant time. */
10
+ export declare function proofChecks(presented: string, secret: string, transcript: Readonly<ProofTranscript>): Promise<boolean>;
11
+ /** The address of the exchange's socket on `port`. */
12
+ export declare function helloUrl(port: number): string;
13
+ /**
14
+ * 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.
15
+ */
16
+ export declare function acquireTicket(port: number, secret: string, timeoutMs?: number): Promise<string | undefined>;
17
+ //# sourceMappingURL=pairing-handshake.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-handshake.d.ts","sourceRoot":"","sources":["../../../../src/bridges/user/web/pairing-handshake.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAUL,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AAE/B,kJAAkJ;AAClJ,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAgCzC,sEAAsE;AACtE,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,QAAQ,CAAC,eAAe,CAAC,GACpC,OAAO,CAAC,MAAM,CAAC,CAOjB;AAED,gJAAgJ;AAChJ,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,QAAQ,CAAC,eAAe,CAAC,GACpC,OAAO,CAAC,OAAO,CAAC,CAQlB;AAED,sDAAsD;AACtD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAMD;;GAEG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,MAA6B,GACvC,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2E7B"}
@@ -0,0 +1,121 @@
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
+ import { HELLO_PATH, HEX_32_PATTERN, NONCE_BYTES, isHelloReply, isHelloTicket, parseHelloMessage, proofInput, } from "./pairing-protocol.js";
5
+ /** 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. */
6
+ export const HANDSHAKE_TIMEOUT_MS = 2000;
7
+ const HEX_RADIX = 16;
8
+ const HEX_DIGITS_PER_BYTE = 2;
9
+ function toHex(bytes) {
10
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0")).join("");
11
+ }
12
+ function fromHex(hex) {
13
+ const bytes = new Uint8Array(hex.length / HEX_DIGITS_PER_BYTE);
14
+ for (let index = 0; index < bytes.length; index++) {
15
+ bytes[index] = Number.parseInt(hex.slice(index * HEX_DIGITS_PER_BYTE, (index + 1) * HEX_DIGITS_PER_BYTE), HEX_RADIX);
16
+ }
17
+ return bytes;
18
+ }
19
+ async function hmacKey(secret) {
20
+ return crypto.subtle.importKey("raw", fromHex(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
21
+ }
22
+ /** The proof a holder of `secret` gives for this exchange, as hex. */
23
+ export async function proofFor(secret, transcript) {
24
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), proofInput(transcript));
25
+ return toHex(new Uint8Array(signature));
26
+ }
27
+ /** Whether `presented` is the proof for this exchange under `secret`. The comparison is the platform's own `verify`, which is constant time. */
28
+ export async function proofChecks(presented, secret, transcript) {
29
+ if (!HEX_32_PATTERN.test(presented))
30
+ return false;
31
+ return crypto.subtle.verify("HMAC", await hmacKey(secret), fromHex(presented), proofInput(transcript));
32
+ }
33
+ /** The address of the exchange's socket on `port`. */
34
+ export function helloUrl(port) {
35
+ return `ws://127.0.0.1:${String(port)}${HELLO_PATH}`;
36
+ }
37
+ function newNonce() {
38
+ return toHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
39
+ }
40
+ /**
41
+ * 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.
42
+ */
43
+ export async function acquireTicket(port, secret, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
44
+ return new Promise((resolve) => {
45
+ let socket;
46
+ try {
47
+ socket = new WebSocket(helloUrl(port));
48
+ }
49
+ catch {
50
+ resolve(undefined);
51
+ return;
52
+ }
53
+ const clientNonce = newNonce();
54
+ let bridgeNonce;
55
+ let settled = false;
56
+ const timer = {};
57
+ const settle = (ticket) => {
58
+ if (settled)
59
+ return;
60
+ settled = true;
61
+ clearTimeout(timer.handle);
62
+ socket.close();
63
+ resolve(ticket);
64
+ };
65
+ timer.handle = setTimeout(() => {
66
+ settle(undefined);
67
+ }, timeoutMs);
68
+ socket.addEventListener("error", () => {
69
+ settle(undefined);
70
+ });
71
+ socket.addEventListener("close", () => {
72
+ settle(undefined);
73
+ });
74
+ socket.addEventListener("open", () => {
75
+ const request = { type: "hello", nonce: clientNonce };
76
+ socket.send(JSON.stringify(request));
77
+ });
78
+ socket.addEventListener("message", (event) => {
79
+ if (typeof event.data !== "string") {
80
+ settle(undefined);
81
+ return;
82
+ }
83
+ const message = parseHelloMessage(event.data);
84
+ if (bridgeNonce === undefined) {
85
+ if (!isHelloReply(message)) {
86
+ settle(undefined);
87
+ return;
88
+ }
89
+ bridgeNonce = message.nonce;
90
+ void proofChecks(message.proof, secret, {
91
+ role: "bridge",
92
+ clientNonce,
93
+ bridgeNonce: message.nonce,
94
+ port,
95
+ })
96
+ .then(async (genuine) => {
97
+ if (!genuine) {
98
+ settle(undefined);
99
+ return;
100
+ }
101
+ const auth = {
102
+ type: "auth",
103
+ proof: await proofFor(secret, {
104
+ role: "client",
105
+ clientNonce,
106
+ bridgeNonce: message.nonce,
107
+ port,
108
+ }),
109
+ };
110
+ socket.send(JSON.stringify(auth));
111
+ })
112
+ .catch(() => {
113
+ settle(undefined);
114
+ });
115
+ return;
116
+ }
117
+ settle(isHelloTicket(message) ? message.ticket : undefined);
118
+ });
119
+ });
120
+ }
121
+ //# sourceMappingURL=pairing-handshake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-handshake.js","sourceRoot":"","sources":["../../../../src/bridges/user/web/pairing-handshake.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,UAAU,EACV,cAAc,EACd,WAAW,EACX,YAAY,EACZ,aAAa,EACb,iBAAiB,EACjB,UAAU,GAIX,MAAM,uBAAuB,CAAC;AAE/B,kJAAkJ;AAClJ,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC;AAEzC,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAE9B,SAAS,KAAK,CAAC,KAAiB;IAC9B,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAChC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,mBAAmB,EAAE,GAAG,CAAC,CAC5D,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACb,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,GAAG,mBAAmB,CAAC,CAAC;IAC/D,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,KAAK,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,QAAQ,CAC5B,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,mBAAmB,EAAE,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,mBAAmB,CAAC,EACzE,SAAS,CACV,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,KAAK,UAAU,OAAO,CAAC,MAAc;IACnC,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAC5B,KAAK,EACL,OAAO,CAAC,MAAM,CAAC,EACf,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EACjC,KAAK,EACL,CAAC,MAAM,EAAE,QAAQ,CAAC,CACnB,CAAC;AACJ,CAAC;AAED,sEAAsE;AACtE,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,MAAc,EACd,UAAqC;IAErC,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CACxC,MAAM,EACN,MAAM,OAAO,CAAC,MAAM,CAAC,EACrB,UAAU,CAAC,UAAU,CAAC,CACvB,CAAC;IACF,OAAO,KAAK,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED,gJAAgJ;AAChJ,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,SAAiB,EACjB,MAAc,EACd,UAAqC;IAErC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CACzB,MAAM,EACN,MAAM,OAAO,CAAC,MAAM,CAAC,EACrB,OAAO,CAAC,SAAS,CAAC,EAClB,UAAU,CAAC,UAAU,CAAC,CACvB,CAAC;AACJ,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,QAAQ,CAAC,IAAY;IACnC,OAAO,kBAAkB,MAAM,CAAC,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;AACvD,CAAC;AAED,SAAS,QAAQ;IACf,OAAO,KAAK,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;AACpE,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAY,EACZ,MAAc,EACd,YAAoB,oBAAoB;IAExC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,IAAI,MAAiB,CAAC;QACtB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,SAAS,CAAC,CAAC;YACnB,OAAO;QACT,CAAC;QACD,MAAM,WAAW,GAAG,QAAQ,EAAE,CAAC;QAC/B,IAAI,WAA+B,CAAC;QACpC,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,KAAK,GAA+C,EAAE,CAAC;QAC7D,MAAM,MAAM,GAAG,CAAC,MAA0B,EAAQ,EAAE;YAClD,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;YAC3B,MAAM,CAAC,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,MAAM,CAAC,CAAC;QAClB,CAAC,CAAC;QACF,KAAK,CAAC,MAAM,GAAG,UAAU,CAAC,GAAG,EAAE;YAC7B,MAAM,CAAC,SAAS,CAAC,CAAC;QACpB,CAAC,EAAE,SAAS,CAAC,CAAC;QACd,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YACpC,MAAM,CAAC,SAAS,CAAC,CAAC;QACpB,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YACpC,MAAM,CAAC,SAAS,CAAC,CAAC;QACpB,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YACnC,MAAM,OAAO,GAAiB,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;YACpE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;QACvC,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,KAAmB,EAAE,EAAE;YACzD,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACnC,MAAM,CAAC,SAAS,CAAC,CAAC;gBAClB,OAAO;YACT,CAAC;YACD,MAAM,OAAO,GAAG,iBAAiB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC9C,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;gBAC9B,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC;oBAC3B,MAAM,CAAC,SAAS,CAAC,CAAC;oBAClB,OAAO;gBACT,CAAC;gBACD,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC;gBAC5B,KAAK,WAAW,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE;oBACtC,IAAI,EAAE,QAAQ;oBACd,WAAW;oBACX,WAAW,EAAE,OAAO,CAAC,KAAK;oBAC1B,IAAI;iBACL,CAAC;qBACC,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;oBACtB,IAAI,CAAC,OAAO,EAAE,CAAC;wBACb,MAAM,CAAC,SAAS,CAAC,CAAC;wBAClB,OAAO;oBACT,CAAC;oBACD,MAAM,IAAI,GAAc;wBACtB,IAAI,EAAE,MAAM;wBACZ,KAAK,EAAE,MAAM,QAAQ,CAAC,MAAM,EAAE;4BAC5B,IAAI,EAAE,QAAQ;4BACd,WAAW;4BACX,WAAW,EAAE,OAAO,CAAC,KAAK;4BAC1B,IAAI;yBACL,CAAC;qBACH,CAAC;oBACF,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;gBACpC,CAAC,CAAC;qBACD,KAAK,CAAC,GAAG,EAAE;oBACV,MAAM,CAAC,SAAS,CAAC,CAAC;gBACpB,CAAC,CAAC,CAAC;gBACL,OAAO;YACT,CAAC;YACD,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QAC9D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,64 @@
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
+ /** The name of the fragment parameter the pairing page opens the dashboard with. */
7
+ export declare const PAIRING_FRAGMENT_PARAM = "pair";
8
+ /** The path of the mesh socket the dashboard's own traffic runs over. */
9
+ export declare const MESH_PATH = "/ws/mesh";
10
+ /** 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. */
11
+ export declare const HELLO_PATH = "/ws/hello";
12
+ /** The subprotocol a server selects for every mesh upgrade that offers the dashboard's protocols, so the offered ticket is never echoed back. */
13
+ export declare const MESH_SUBPROTOCOL = "agent-comms";
14
+ /** 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. */
15
+ export declare const TICKET_SUBPROTOCOL_PREFIX = "agent-comms-ticket.";
16
+ /** What a secret looks like: exactly 32 bytes in lower-case hex. */
17
+ export declare const PAIRING_SECRET_PATTERN: RegExp;
18
+ /** What a nonce, a proof and a ticket look like: 32 bytes in lower-case hex. */
19
+ export declare const HEX_32_PATTERN: RegExp;
20
+ /** Random bytes in a nonce, and in a ticket. */
21
+ export declare const NONCE_BYTES = 32;
22
+ /** 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. */
23
+ export type ProofRole = "bridge" | "client";
24
+ /** The dashboard's first message: its fresh nonce. */
25
+ export interface HelloRequest {
26
+ type: "hello";
27
+ nonce: string;
28
+ }
29
+ /** The bridge's answer: its own fresh nonce and its proof over both. */
30
+ export interface HelloReply {
31
+ type: "hello-ok";
32
+ nonce: string;
33
+ proof: string;
34
+ }
35
+ /** The dashboard's proof, sent only after the bridge's checked out. */
36
+ export interface HelloAuth {
37
+ type: "auth";
38
+ proof: string;
39
+ }
40
+ /** The bridge's last message: the single-use ticket for one mesh socket. */
41
+ export interface HelloTicket {
42
+ type: "ticket";
43
+ ticket: string;
44
+ }
45
+ /** Reads one JSON message of the exchange, or undefined for text that is not JSON. The caller narrows the result with the guards below. */
46
+ export declare function parseHelloMessage(text: string): unknown;
47
+ export declare function isHelloRequest(value: unknown): value is HelloRequest;
48
+ export declare function isHelloReply(value: unknown): value is HelloReply;
49
+ export declare function isHelloAuth(value: unknown): value is HelloAuth;
50
+ export declare function isHelloTicket(value: unknown): value is HelloTicket;
51
+ /** 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. */
52
+ export interface ProofTranscript {
53
+ role: ProofRole;
54
+ clientNonce: string;
55
+ bridgeNonce: string;
56
+ port: number;
57
+ }
58
+ /**
59
+ * 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.
60
+ */
61
+ export declare function proofInput(transcript: Readonly<ProofTranscript>): Uint8Array<ArrayBuffer>;
62
+ /** The subprotocols the dashboard offers when it opens a mesh socket: the fixed one, and the one carrying its ticket when it has one. */
63
+ export declare function meshSocketProtocols(ticket: string | undefined): string[];
64
+ //# sourceMappingURL=pairing-protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-protocol.d.ts","sourceRoot":"","sources":["../../../../src/bridges/user/web/pairing-protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,oFAAoF;AACpF,eAAO,MAAM,sBAAsB,SAAS,CAAC;AAE7C,yEAAyE;AACzE,eAAO,MAAM,SAAS,aAAa,CAAC;AAEpC,8JAA8J;AAC9J,eAAO,MAAM,UAAU,cAAc,CAAC;AAEtC,iJAAiJ;AACjJ,eAAO,MAAM,gBAAgB,gBAAgB,CAAC;AAE9C,iMAAiM;AACjM,eAAO,MAAM,yBAAyB,wBAAwB,CAAC;AAE/D,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AAEvD,gFAAgF;AAChF,eAAO,MAAM,cAAc,QAAmB,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,0NAA0N;AAC1N,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5C,sDAAsD;AACtD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wEAAwE;AACxE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD,2IAA2I;AAC3I,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAOhE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAE9D;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAElE;AAED,gLAAgL;AAChL,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,UAAU,EAAE,QAAQ,CAAC,eAAe,CAAC,GACpC,UAAU,CAAC,WAAW,CAAC,CAKzB;AAED,yIAAyI;AACzI,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAIxE"}
@@ -0,0 +1,65 @@
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
+ /** The name of the fragment parameter the pairing page opens the dashboard with. */
7
+ export const PAIRING_FRAGMENT_PARAM = "pair";
8
+ /** The path of the mesh socket the dashboard's own traffic runs over. */
9
+ export const MESH_PATH = "/ws/mesh";
10
+ /** 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. */
11
+ export const HELLO_PATH = "/ws/hello";
12
+ /** The subprotocol a server selects for every mesh upgrade that offers the dashboard's protocols, so the offered ticket is never echoed back. */
13
+ export const MESH_SUBPROTOCOL = "agent-comms";
14
+ /** 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. */
15
+ export const TICKET_SUBPROTOCOL_PREFIX = "agent-comms-ticket.";
16
+ /** What a secret looks like: exactly 32 bytes in lower-case hex. */
17
+ export const PAIRING_SECRET_PATTERN = /^[0-9a-f]{64}$/;
18
+ /** What a nonce, a proof and a ticket look like: 32 bytes in lower-case hex. */
19
+ export const HEX_32_PATTERN = /^[0-9a-f]{64}$/;
20
+ /** Random bytes in a nonce, and in a ticket. */
21
+ export const NONCE_BYTES = 32;
22
+ function isRecord(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+ function hex32(value) {
26
+ return typeof value === "string" && HEX_32_PATTERN.test(value);
27
+ }
28
+ /** Reads one JSON message of the exchange, or undefined for text that is not JSON. The caller narrows the result with the guards below. */
29
+ export function parseHelloMessage(text) {
30
+ try {
31
+ return JSON.parse(text);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ export function isHelloRequest(value) {
38
+ return isRecord(value) && value.type === "hello" && hex32(value.nonce);
39
+ }
40
+ export function isHelloReply(value) {
41
+ return (isRecord(value) &&
42
+ value.type === "hello-ok" &&
43
+ hex32(value.nonce) &&
44
+ hex32(value.proof));
45
+ }
46
+ export function isHelloAuth(value) {
47
+ return isRecord(value) && value.type === "auth" && hex32(value.proof);
48
+ }
49
+ export function isHelloTicket(value) {
50
+ return isRecord(value) && value.type === "ticket" && hex32(value.ticket);
51
+ }
52
+ /**
53
+ * 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.
54
+ */
55
+ export function proofInput(transcript) {
56
+ const { role, clientNonce, bridgeNonce, port } = transcript;
57
+ return new TextEncoder().encode([role, clientNonce, bridgeNonce, String(port)].join("\0"));
58
+ }
59
+ /** The subprotocols the dashboard offers when it opens a mesh socket: the fixed one, and the one carrying its ticket when it has one. */
60
+ export function meshSocketProtocols(ticket) {
61
+ return ticket === undefined
62
+ ? [MESH_SUBPROTOCOL]
63
+ : [MESH_SUBPROTOCOL, `${TICKET_SUBPROTOCOL_PREFIX}${ticket}`];
64
+ }
65
+ //# sourceMappingURL=pairing-protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-protocol.js","sourceRoot":"","sources":["../../../../src/bridges/user/web/pairing-protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,oFAAoF;AACpF,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC;AAE7C,yEAAyE;AACzE,MAAM,CAAC,MAAM,SAAS,GAAG,UAAU,CAAC;AAEpC,8JAA8J;AAC9J,MAAM,CAAC,MAAM,UAAU,GAAG,WAAW,CAAC;AAEtC,iJAAiJ;AACjJ,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAC;AAE9C,iMAAiM;AACjM,MAAM,CAAC,MAAM,yBAAyB,GAAG,qBAAqB,CAAC;AAE/D,oEAAoE;AACpE,MAAM,CAAC,MAAM,sBAAsB,GAAG,gBAAgB,CAAC;AAEvD,gFAAgF;AAChF,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAE/C,gDAAgD;AAChD,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AA8B9B,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,KAAK,CAAC,KAAc;IAC3B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACjE,CAAC;AAED,2IAA2I;AAC3I,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,KAAK,CAAC,IAAI,KAAK,UAAU;QACzB,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC;QAClB,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CACnB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AACxE,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;AAC3E,CAAC;AAUD;;GAEG;AACH,MAAM,UAAU,UAAU,CACxB,UAAqC;IAErC,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC;IAC5D,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAC7B,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAC1D,CAAC;AACJ,CAAC;AAED,yIAAyI;AACzI,MAAM,UAAU,mBAAmB,CAAC,MAA0B;IAC5D,OAAO,MAAM,KAAK,SAAS;QACzB,CAAC,CAAC,CAAC,gBAAgB,CAAC;QACpB,CAAC,CAAC,CAAC,gBAAgB,EAAE,GAAG,yBAAyB,GAAG,MAAM,EAAE,CAAC,CAAC;AAClE,CAAC"}
@@ -56,6 +56,12 @@ export declare function createWebServer(options?: {
56
56
  localClientAddresses?: (() => ReadonlySet<string>) | undefined;
57
57
  /** 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. */
58
58
  beaconPort?: number | undefined;
59
+ /** The directory the pairing secret is kept in. Defaults to the bridge's data directory; tests pass one of their own. */
60
+ pairingDir?: string | undefined;
61
+ /** 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. */
62
+ watchPairingFile?: boolean | undefined;
63
+ /** 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. */
64
+ pairingSweepMs?: number | undefined;
59
65
  existingController?: ChatController | undefined;
60
66
  coordinatorPort?: number | undefined;
61
67
  /** Overrides the first-contact UDP port a fresh controller's store runs presence on (ChatController's own firstContactPort); ignored when existingController is supplied. */
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../src/bridges/user/web/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAErC,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAG7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAsB1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA6E1D,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,UAAU,EAAE,cAAc,CAAC;IAC3B,GAAG,EAAE,eAAe,CAAC;IACrB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,2HAA2H;IAC3H,IAAI,EAAE,MAAM,CAAC;IACb,uIAAuI;IACvI,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,8JAA8J;AAC9J,wBAAgB,UAAU,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAGtE;AAED,0VAA0V;AAC1V,wBAAgB,eAAe,CAC7B,MAAM,EAAE,eAAe,GAAG,SAAS,GAClC,YAAY,CAKd;AAMD;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,QAAQ,CAAC;IAAE,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC,GAC1D,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAUtC;AAgED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,OAAO,CAAC,EAAE;IAC9C,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,sOAAsO;IACtO,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,iMAAiM;IACjM,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,uQAAuQ;IACvQ,oBAAoB,CAAC,EAAE,CAAC,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IAC/D,2JAA2J;IAC3J,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,kBAAkB,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IAChD,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,6KAA6K;IAC7K,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,gQAAgQ;IAChQ,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,wHAAwH;IACxH,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,yLAAyL;IACzL,6BAA6B,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpD,GAAG,OAAO,CAAC,eAAe,CAAC,CAoJ3B;AAyFD,wBAAsB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB3E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../src/bridges/user/web/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAErC,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAG7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAsB1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAoG1D,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,UAAU,EAAE,cAAc,CAAC;IAC3B,GAAG,EAAE,eAAe,CAAC;IACrB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,2HAA2H;IAC3H,IAAI,EAAE,MAAM,CAAC;IACb,uIAAuI;IACvI,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,8JAA8J;AAC9J,wBAAgB,UAAU,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAGtE;AAED,0VAA0V;AAC1V,wBAAgB,eAAe,CAC7B,MAAM,EAAE,eAAe,GAAG,SAAS,GAClC,YAAY,CAKd;AAMD;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,QAAQ,CAAC;IAAE,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC,GAC1D,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAUtC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,OAAO,CAAC,EAAE;IAC9C,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,sOAAsO;IACtO,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,iMAAiM;IACjM,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,uQAAuQ;IACvQ,oBAAoB,CAAC,EAAE,CAAC,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IAC/D,2JAA2J;IAC3J,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,yHAAyH;IACzH,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,oMAAoM;IACpM,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,+LAA+L;IAC/L,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,kBAAkB,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IAChD,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,6KAA6K;IAC7K,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,gQAAgQ;IAChQ,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,wHAAwH;IACxH,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,yLAAyL;IACzL,6BAA6B,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpD,GAAG,OAAO,CAAC,eAAe,CAAC,CAuN3B;AAyFD,wBAAsB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB3E"}
@@ -7,7 +7,6 @@
7
7
  *
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
- import { once } from "node:events";
11
10
  import * as http from "node:http";
12
11
  import { fileURLToPath } from "node:url";
13
12
  import { WebSocketServer } from "ws";
@@ -20,14 +19,20 @@ import { getMeshGraphRead, getMeshTraceRead, getRoomMessagesRead, } from "./read
20
19
  import { MeshEventPublisher } from "./event-publisher.js";
21
20
  import { meshRouter } from "./router.js";
22
21
  import { resolveWebConsoleDist, serveWebConsole, } from "./web-console-static.js";
23
- import { ACCESS_QUERY_PARAM, accessUrls, authorise, generateAccessToken, localWebUrl, ownInterfaceAddresses, requiresAccessToken, resolveWebBindHost, } from "./web-access.js";
22
+ import { ACCESS_QUERY_PARAM, accessUrls, authorise, isHostedDashboardOrigin, isOwnClient, generateAccessToken, localWebUrl, ownInterfaceAddresses, requiresAccessToken, resolveWebBindHost, } from "./web-access.js";
24
23
  import { bridgePortRange } from "./bridge-port-range.js";
24
+ import { listen } from "./web-listen.js";
25
+ import { HOSTED_DASHBOARD_ORIGIN } from "./hosted-dashboard.js";
25
26
  import { startWebBeacon } from "./web-beacon.js";
26
27
  import { REQUEST_TIMEOUT_MS, meshErrorMessage, invalidJsonBody, readBoundedBody, serveRoute, } from "./request-handling.js";
28
+ import { PAIRING_PAGE_HEADERS, PAIRING_PAGE_PATH, openPairingSecret, watchPairingSecret, pairingPageHtml, selectMeshSubprotocol, } from "./web-pairing.js";
29
+ import { MAX_HELLO_MESSAGE_BYTES, MAX_HELLO_SOCKETS, ROTATION_SWEEP_MS, createHostedSocketGuard, createTicketBook, serveHello, } from "./web-pairing-exchange.js";
30
+ import { HELLO_PATH, MESH_PATH } from "./pairing-protocol.js";
27
31
  import { createFrontendAssetLoader, FrontendNotBuiltError, } from "./web-assets.js";
28
32
  const HTTP_OK = 200;
29
33
  const HTTP_BAD_REQUEST = 400;
30
34
  const HTTP_UNAUTHORIZED = 401;
35
+ const HTTP_FORBIDDEN = 403;
31
36
  const HTTP_NOT_FOUND = 404;
32
37
  const HTTP_INTERNAL_SERVER_ERROR = 500;
33
38
  const HTTP_NOT_IMPLEMENTED = 501;
@@ -89,54 +94,6 @@ export async function tryStartBridgeWebServer(store, harness, options) {
89
94
  }),
90
95
  });
91
96
  }
92
- /** Whether a listen failure means the port is already taken. */
93
- function isAddressInUse(error) {
94
- return (typeof error === "object" &&
95
- error !== null &&
96
- "code" in error &&
97
- error.code === "EADDRINUSE");
98
- }
99
- /** Binds `server` to `port` on `host`; true once listening, false when the port is taken, and rejects on any other failure. */
100
- async function tryListen(server, port, host) {
101
- // once() rejects when the server emits "error" before "listening", and removes both of its listeners either way.
102
- const listening = once(server, "listening");
103
- server.listen(port, host);
104
- try {
105
- await listening;
106
- return true;
107
- }
108
- catch (error) {
109
- if (isAddressInUse(error))
110
- return false;
111
- throw error;
112
- }
113
- }
114
- /**
115
- * Binds `server` to the first of `ports` that is free, in order, and returns the port it holds.
116
- *
117
- * 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.
118
- */
119
- async function listen(server, host, ports, fallBackToAnyPort) {
120
- for (const port of ports) {
121
- if (await tryListen(server, port, host))
122
- return boundPortOf(server);
123
- }
124
- if (fallBackToAnyPort && (await tryListen(server, 0, host))) {
125
- return boundPortOf(server);
126
- }
127
- const tried = fallBackToAnyPort
128
- ? [...ports.map(String), "an operating-system port"]
129
- : ports.map(String);
130
- throw new Error(`Agent Comms web UI: every port tried is already in use on ${host}: ${tried.join(", ")}`);
131
- }
132
- /** The port a listening server holds. */
133
- function boundPortOf(server) {
134
- const addr = server.address();
135
- if (typeof addr !== "object" || addr === null) {
136
- throw new Error("Agent Comms web UI: the listening server has no address");
137
- }
138
- return addr.port;
139
- }
140
97
  /**
141
98
  * Create the web server and resolve once it is listening.
142
99
  *
@@ -149,7 +106,7 @@ function boundPortOf(server) {
149
106
  * Dashboard controller is created.
150
107
  */
151
108
  export async function createWebServer(options) {
152
- const { port, bridgePortBase, host: requestedHost, localClientAddresses = ownInterfaceAddresses, beaconPort, existingController, coordinatorPort, firstContactPort, hubUrl, requestTimeoutMs = REQUEST_TIMEOUT_MS, connectionsCheckingIntervalMs, } = options ?? {};
109
+ const { port, bridgePortBase, host: requestedHost, localClientAddresses = ownInterfaceAddresses, beaconPort, pairingDir, watchPairingFile = true, pairingSweepMs = ROTATION_SWEEP_MS, existingController, coordinatorPort, firstContactPort, hubUrl, requestTimeoutMs = REQUEST_TIMEOUT_MS, connectionsCheckingIntervalMs, } = options ?? {};
153
110
  const controller = existingController ??
154
111
  new ChatController("Dashboard", {
155
112
  coordinatorPort,
@@ -165,11 +122,21 @@ export async function createWebServer(options) {
165
122
  ? generateAccessToken()
166
123
  : undefined;
167
124
  // 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.
125
+ const pairing = openPairingSecret({
126
+ dir: pairingDir,
127
+ onReplaced: (file) => {
128
+ process.stderr.write(`agent-comms: ${file} was missing or unusable and has been replaced, so browsers paired with the hosted dashboard must pair again\n`);
129
+ },
130
+ });
131
+ const tickets = createTicketBook(pairing);
132
+ const hostedSockets = createHostedSocketGuard(pairing);
168
133
  const accessPolicy = {
169
134
  token: accessToken,
170
135
  bindHost: host,
171
136
  port: port ?? 0,
172
137
  localClientAddresses,
138
+ // Tickets are only accepted under the secret in force, so `agent-comms pairing rotate` refuses every ticket issued before it without a restart.
139
+ tickets,
173
140
  };
174
141
  const server = http.createServer({
175
142
  requestTimeout: requestTimeoutMs,
@@ -183,7 +150,11 @@ export async function createWebServer(options) {
183
150
  try {
184
151
  if (!admitRequest(accessPolicy, req, res))
185
152
  return;
186
- handleRequest(req, res, controller, webConsoleDist);
153
+ handleRequest(req, res, controller, {
154
+ pairing,
155
+ isOwnClient: isOwnClient(accessPolicy, req.socket.remoteAddress),
156
+ webConsoleDist,
157
+ });
187
158
  }
188
159
  catch (err) {
189
160
  // A request the synchronous routing cannot handle is answered, not thrown: an exception out of this listener would end the process.
@@ -215,8 +186,36 @@ export async function createWebServer(options) {
215
186
  const wss = new WebSocketServer({
216
187
  noServer: true,
217
188
  maxPayload: MAX_MESSAGE_BYTES,
189
+ // The dashboard offers the mesh subprotocol beside its ticket; selecting only the fixed name keeps the ticket from being echoed.
190
+ handleProtocols: selectMeshSubprotocol,
191
+ });
192
+ // The proof exchange's own socket: tiny messages only, and a handful at a time.
193
+ const helloWss = new WebSocketServer({
194
+ noServer: true,
195
+ maxPayload: MAX_HELLO_MESSAGE_BYTES,
218
196
  });
219
197
  const rpcHandler = new RPCHandler(meshRouter);
198
+ // A socket admitted under a secret that has since been replaced is ended, also when it is idle.
199
+ const rotationSweep = setInterval(() => {
200
+ hostedSockets.sweep();
201
+ }, pairingSweepMs);
202
+ rotationSweep.unref();
203
+ // 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.
204
+ // Opening the secret creates its directory, which the watch needs to exist.
205
+ pairing.current();
206
+ const stopWatching = (watchPairingFile ? watchPairingSecret : () => () => undefined)({
207
+ dir: pairingDir,
208
+ onChange: () => {
209
+ hostedSockets.sweep();
210
+ },
211
+ onError: (error) => {
212
+ process.stderr.write(`agent-comms: cannot watch the pairing secret, so a rotation ends hosted sockets at the next sweep: ${error.message}\n`);
213
+ },
214
+ });
215
+ server.once("close", () => {
216
+ clearInterval(rotationSweep);
217
+ stopWatching();
218
+ });
220
219
  server.on("upgrade", (req, socket, head) => {
221
220
  // A socket error before or after the upgrade (a peer that resets, say) is that connection's alone.
222
221
  socket.on("error", reportSocketError);
@@ -227,22 +226,36 @@ export async function createWebServer(options) {
227
226
  socket.destroy();
228
227
  return;
229
228
  }
230
- if (req.url === "/ws/mesh") {
229
+ if (req.url === MESH_PATH) {
231
230
  wss.handleUpgrade(req, socket, head, (ws) => {
232
231
  wss.emit("connection", ws, req);
233
232
  });
234
233
  }
234
+ else if (req.url === HELLO_PATH &&
235
+ helloWss.clients.size < MAX_HELLO_SOCKETS) {
236
+ helloWss.handleUpgrade(req, socket, head, (ws) => {
237
+ serveHello(ws, {
238
+ secret: pairing,
239
+ tickets,
240
+ port: () => accessPolicy.port,
241
+ });
242
+ });
243
+ }
235
244
  else {
236
245
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
237
246
  socket.destroy();
238
247
  }
239
248
  });
240
- wss.on("connection", (ws) => {
249
+ wss.on("connection", (ws, req) => {
241
250
  // 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.
242
251
  ws.on("error", reportSocketError);
252
+ if (isHostedDashboardOrigin(req.headers.origin))
253
+ hostedSockets.track(ws);
243
254
  // 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().
244
255
  const context = { controller, publisher };
245
256
  ws.on("message", (data, isBinary) => {
257
+ if (!hostedSockets.check(ws))
258
+ return;
246
259
  // "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.
247
260
  const raw = Array.isArray(data) ? Buffer.concat(data) : data;
248
261
  const bytes = raw instanceof ArrayBuffer ? Buffer.from(raw) : raw;
@@ -256,6 +269,7 @@ export async function createWebServer(options) {
256
269
  });
257
270
  // 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.
258
271
  console.error(`Agent Comms web UI: ${localWebUrl(host, boundPort)}`);
272
+ console.error(`To pair the hosted dashboard with this bridge, open ${localWebUrl(host, boundPort)}${PAIRING_PAGE_PATH} in the browser you use`);
259
273
  if (accessToken !== undefined) {
260
274
  announceLanAccess(server, { ...accessPolicy, token: accessToken }, controller.meshStore.peerId, beaconPort);
261
275
  }
@@ -343,10 +357,23 @@ export async function runWeb(userName, port) {
343
357
  process.on("SIGINT", shutDown);
344
358
  process.on("SIGTERM", shutDown);
345
359
  }
346
- // ---------------------------------------------------------------------------
347
- // HTTP handler
348
- // ---------------------------------------------------------------------------
349
- function handleRequest(req, res, controller, webConsoleDist) {
360
+ /** 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`. */
361
+ const PERSON_INITIATED_FETCH_SITES = new Set(["none", "same-origin"]);
362
+ /** 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. */
363
+ function servePairingPage(req, res, context) {
364
+ const site = req.headers["sec-fetch-site"];
365
+ const fromAnotherSite = typeof site === "string" && !PERSON_INITIATED_FETCH_SITES.has(site);
366
+ // A page of the hosted dashboard, paired or not, is never the person this page is for.
367
+ const fromHostedDashboard = req.headers.origin === HOSTED_DASHBOARD_ORIGIN;
368
+ if (!context.isOwnClient || fromAnotherSite || fromHostedDashboard) {
369
+ res.writeHead(HTTP_FORBIDDEN, { "Content-Type": "text/plain" });
370
+ res.end("Forbidden");
371
+ return;
372
+ }
373
+ res.writeHead(HTTP_OK, PAIRING_PAGE_HEADERS);
374
+ res.end(pairingPageHtml(context.pairing.current()));
375
+ }
376
+ function handleRequest(req, res, controller, context) {
350
377
  let url;
351
378
  try {
352
379
  url = new URL(req.url ?? "/", `http://localhost`);
@@ -357,9 +384,13 @@ function handleRequest(req, res, controller, webConsoleDist) {
357
384
  return;
358
385
  }
359
386
  // wire-mesh's web-console — an alternate, generic reference-client UI, served only when AGENT_COMMS_WEB_CONSOLE_DIST resolves to a real build.
360
- if (webConsoleDist !== undefined &&
387
+ if (context.webConsoleDist !== undefined &&
361
388
  req.method === "GET" &&
362
- serveWebConsole(webConsoleDist, url.pathname, res)) {
389
+ serveWebConsole(context.webConsoleDist, url.pathname, res)) {
390
+ return;
391
+ }
392
+ if (url.pathname === PAIRING_PAGE_PATH && req.method === "GET") {
393
+ servePairingPage(req, res, context);
363
394
  return;
364
395
  }
365
396
  // Frontend HTML -- also served at the literal /index.html path (not just