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
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The per-install pairing secret that admits the hosted dashboard (web-access.ts).
3
+ *
4
+ * The hosted dashboard's origin is shared by every page published under the same account, so an origin check alone cannot say which page is calling, and a listener on a port of the range cannot be told from the bridge by the handshake alone. Both directions are therefore authenticated by a secret this module keeps: a random value the bridge generated, held in an owner-only file beside the bridge's other state, shown to a person only on the bridge's own loopback pairing page and handed from there to the dashboard in a URL fragment, which no server receives and no log records.
5
+ *
6
+ * Design, in one place:
7
+ * - Generation: 32 bytes from the platform's cryptographic random source, written as 64 lower-case hex characters, with owner-only permissions in an owner-only directory. A file that is missing is created exclusively, so two bridges starting together settle on one secret. A file that does not hold exactly that form, or whose mode or owner is not the owner-only one it was written with, is replaced by a fresh secret, which un-pairs every browser: the safe direction.
8
+ * - The secret never crosses a socket. The dashboard opens `/ws/hello` on a port and sends a fresh nonce; the bridge answers with its own nonce and a proof, an HMAC-SHA-256 keyed with the secret over the role, both nonces and the port; the dashboard checks that proof (so a listener that does not hold the secret is not taken for the bridge, and receives nothing but a nonce) and only then sends its own proof, over the nonces in the same order under the other role. The bridge checks it and answers with a single-use ticket that lives a few seconds, which the dashboard offers as a subprotocol, `agent-comms-ticket.<ticket>`, on the mesh socket. The server answers every such upgrade with the fixed subprotocol `agent-comms`, so the ticket is never echoed.
9
+ * - Comparison: constant time (`timingSafeEqual`) over fixed-length values.
10
+ * - Revocation: `rotatePairingSecret` writes a new secret. The running bridge reads the file again whenever its inode, modification time or size changes, so a rotation refuses every later ticket and proof without a restart, and the server closes every socket it admitted under the old secret the moment the file is reported replaced (watchPairingSecret), with a periodic check as the backstop (server.ts).
11
+ * - Pairing: once per browser profile, by a step that proves the person is at this machine: the pairing page is served only to a client on this machine that carries no hosted-dashboard Origin, so a page on the hosted origin cannot fetch or frame it, and it hands the secret over only when the person presses its button, by opening the hosted dashboard at an exact, fixed address with the secret in the fragment. The dashboard stores it in its own storage, removes the fragment from the address, and uses it only as the key of the proof.
12
+ * - Limit: the dashboard keeps the secret in the storage of its own origin, which every page of that origin can read. The secret separates a page that was never paired in this browser from one that was; it cannot separate pages that share the dashboard's storage. A separation between pages of one origin needs the dashboard on an origin of its own.
13
+ */
14
+
15
+ import * as crypto from "node:crypto";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { resolveDataDir } from "../../../core/data-directory.js";
19
+ import {
20
+ createFileExclusive,
21
+ writeFileAtomic,
22
+ } from "../../../core/atomic-file.js";
23
+ import { HOSTED_DASHBOARD_URL } from "./hosted-dashboard.js";
24
+ import {
25
+ MESH_SUBPROTOCOL,
26
+ PAIRING_FRAGMENT_PARAM,
27
+ PAIRING_SECRET_PATTERN,
28
+ TICKET_SUBPROTOCOL_PREFIX,
29
+ } from "./pairing-protocol.js";
30
+
31
+ /** The route the bridge serves its pairing page at. */
32
+ export const PAIRING_PAGE_PATH = "/pair";
33
+
34
+ /** Random bytes in a secret: 256 bits. */
35
+ const PAIRING_SECRET_BYTES = 32;
36
+
37
+ /** Owner-only file and directory modes, as for every other file holding a credential. */
38
+ const SECRET_FILE_MODE = 0o600;
39
+ const SECRET_DIR_MODE = 0o700;
40
+ /** The mode bits that give a group or anyone else access. */
41
+ const GROUP_AND_OTHER_BITS = 0o077;
42
+
43
+ /** The file the secret lives in, beside the bridge's other persistent state. */
44
+ export function pairingSecretFile(dir?: string): string {
45
+ return path.join(resolveDataDir({ dir }), "web-pairing-secret");
46
+ }
47
+
48
+ function newSecret(): string {
49
+ return crypto.randomBytes(PAIRING_SECRET_BYTES).toString("hex");
50
+ }
51
+
52
+ /** Whether a file or directory is owned by this user and closed to everyone else. Always true where the platform has no such ownership (Windows). */
53
+ function isOwnerOnly(stat: Readonly<fs.Stats>): boolean {
54
+ const uid = process.getuid?.();
55
+ if (uid === undefined) return true;
56
+ return stat.uid === uid && (stat.mode & GROUP_AND_OTHER_BITS) === 0;
57
+ }
58
+
59
+ /** Creates the directory owner-only, and closes an existing one of this user's that was left open to others. A directory that belongs to someone else is not this bridge's to keep a secret in. */
60
+ function ensureSecretDir(dir: string): void {
61
+ fs.mkdirSync(dir, { recursive: true, mode: SECRET_DIR_MODE });
62
+ const stat = fs.statSync(dir);
63
+ if (isOwnerOnly(stat)) return;
64
+ if (stat.uid !== process.getuid?.()) {
65
+ throw new Error(
66
+ `${dir} does not belong to this user, so the pairing secret is not kept in it`,
67
+ );
68
+ }
69
+ fs.chmodSync(dir, SECRET_DIR_MODE);
70
+ }
71
+
72
+ function writeSecret(file: string, secret: string): void {
73
+ ensureSecretDir(path.dirname(file));
74
+ writeFileAtomic(file, `${secret}\n`, SECRET_FILE_MODE);
75
+ }
76
+
77
+ /**
78
+ * Replaces the secret with a fresh one, so every browser that was paired has to pair again and every socket admitted under the old one is closed. Returns nothing: the secret is shown only by the pairing page.
79
+ */
80
+ export function rotatePairingSecret(dir?: string): void {
81
+ writeSecret(pairingSecretFile(dir), newSecret());
82
+ }
83
+
84
+ /** Reads the secret a file holds, or undefined when the file is missing, does not hold the expected form, or is open to anyone but its owner (a secret that anyone else could have read is not kept). */
85
+ function readSecret(file: string): string | undefined {
86
+ try {
87
+ if (!isOwnerOnly(fs.statSync(file))) return undefined;
88
+ const text = fs.readFileSync(file, "utf-8").trim();
89
+ return PAIRING_SECRET_PATTERN.test(text) ? text : undefined;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /** The secret a running bridge proves possession of. */
96
+ export interface PairingSecretStore {
97
+ /** The current secret. Read again when the file changed, so a rotation by another process is seen at once. */
98
+ current: () => string;
99
+ /** A number that changes whenever `current` has returned a different secret from the one before. Read after `current`, so a holder of an earlier secret can tell it has been replaced. */
100
+ generation: () => number;
101
+ }
102
+
103
+ /**
104
+ * Opens the pairing secret at `options.dir` (default: the bridge's data directory), creating it when it does not exist or is unusable. `onReplaced` is told when an existing file had to be replaced, so the operator hears that browsers must pair again.
105
+ */
106
+ export function openPairingSecret(
107
+ options?: Readonly<{
108
+ dir?: string | undefined;
109
+ onReplaced?: (file: string) => void;
110
+ }>,
111
+ ): PairingSecretStore {
112
+ const file = pairingSecretFile(options?.dir);
113
+ let cached: { secret: string; stamp: string } | undefined;
114
+ let generation = 0;
115
+
116
+ const stampOf = (): string | undefined => {
117
+ try {
118
+ const stat = fs.statSync(file);
119
+ // Every write replaces the file, so its inode changes with its content: two rotations within one clock tick and of one size are still told apart.
120
+ return `${String(stat.ino)}:${String(stat.mtimeMs)}:${String(stat.size)}`;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ };
125
+
126
+ const load = (): { secret: string; stamp: string } => {
127
+ // Whether a file was there is read before the file is, so a file another bridge creates in between is not mistaken for an unusable one and replaced.
128
+ const existed = fs.existsSync(file);
129
+ const existing = readSecret(file);
130
+ if (existing !== undefined) {
131
+ return { secret: existing, stamp: stampOf() ?? "" };
132
+ }
133
+ const secret = newSecret();
134
+ if (existed) {
135
+ writeSecret(file, secret);
136
+ options?.onReplaced?.(file);
137
+ return { secret, stamp: stampOf() ?? "" };
138
+ }
139
+ ensureSecretDir(path.dirname(file));
140
+ // Created exclusively, so a bridge that starts at the same moment settles on this secret or the other's and not two.
141
+ if (createFileExclusive(file, `${secret}\n`, SECRET_FILE_MODE)) {
142
+ return { secret, stamp: stampOf() ?? "" };
143
+ }
144
+ const winner = readSecret(file);
145
+ if (winner === undefined) {
146
+ writeSecret(file, secret);
147
+ return { secret, stamp: stampOf() ?? "" };
148
+ }
149
+ return { secret: winner, stamp: stampOf() ?? "" };
150
+ };
151
+
152
+ return {
153
+ current: () => {
154
+ const stamp = stampOf();
155
+ if (
156
+ cached === undefined ||
157
+ stamp === undefined ||
158
+ stamp !== cached.stamp
159
+ ) {
160
+ const loaded = load();
161
+ if (cached !== undefined && loaded.secret !== cached.secret) {
162
+ generation += 1;
163
+ }
164
+ cached = loaded;
165
+ }
166
+ return cached.secret;
167
+ },
168
+ generation: () => generation,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Calls `onChange` as soon as the operating system reports a change to the directory holding the secret file, so that a rotation made by another process is acted on at once and not at the next poll. Returns the function that stops watching. A watch that cannot be started, or that fails later, is reported to `onError` and the caller's periodic check still ends what a rotation revokes: the platform does not promise every change is reported (a network file system may report none).
174
+ */
175
+ export function watchPairingSecret(
176
+ options: Readonly<{
177
+ dir?: string | undefined;
178
+ onChange: () => void;
179
+ onError: (error: Error) => void;
180
+ }>,
181
+ ): () => void {
182
+ const file = pairingSecretFile(options.dir);
183
+ const name = path.basename(file);
184
+ try {
185
+ const watcher = fs.watch(
186
+ path.dirname(file),
187
+ { persistent: false },
188
+ (_event, changed) => {
189
+ if (changed === null || changed === name) options.onChange();
190
+ },
191
+ );
192
+ watcher.on("error", options.onError);
193
+ return () => {
194
+ watcher.close();
195
+ };
196
+ } catch (error) {
197
+ options.onError(error instanceof Error ? error : new Error(String(error)));
198
+ return () => undefined;
199
+ }
200
+ }
201
+
202
+ /** The offered subprotocols of an upgrade, as the `Sec-WebSocket-Protocol` header lists them. */
203
+ function offeredSubprotocols(
204
+ header: string | readonly string[] | undefined,
205
+ ): string[] {
206
+ const value = typeof header === "string" ? header : header?.join(",");
207
+ return value === undefined
208
+ ? []
209
+ : value
210
+ .split(",")
211
+ .map((entry) => entry.trim())
212
+ .filter((entry) => entry !== "");
213
+ }
214
+
215
+ /** The ticket a mesh upgrade presents in an offered ticket subprotocol, or undefined when it presents none. */
216
+ export function presentedTicket(
217
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>,
218
+ ): string | undefined {
219
+ for (const protocol of offeredSubprotocols(
220
+ headers["sec-websocket-protocol"],
221
+ )) {
222
+ if (protocol.startsWith(TICKET_SUBPROTOCOL_PREFIX)) {
223
+ return protocol.slice(TICKET_SUBPROTOCOL_PREFIX.length);
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /**
230
+ * Which subprotocol a WebSocket server selects: the fixed mesh subprotocol when the client offers it, and none otherwise (a client that offers no protocols, as a page served by the bridge itself does not, gets none). The offered secret is never selected.
231
+ */
232
+ export function selectMeshSubprotocol(
233
+ protocols: ReadonlySet<string>,
234
+ ): string | false {
235
+ return protocols.has(MESH_SUBPROTOCOL) ? MESH_SUBPROTOCOL : false;
236
+ }
237
+
238
+ /** The address the pairing page sends the person to: the hosted dashboard, with the secret in the fragment. */
239
+ export function dashboardPairingUrl(secret: string): string {
240
+ return `${HOSTED_DASHBOARD_URL}#${PAIRING_FRAGMENT_PARAM}=${secret}`;
241
+ }
242
+
243
+ /**
244
+ * The pairing page. It carries the secret only inside a link that opens the dashboard at its fixed address, and a person has to press it. It tells no one anything on its own and may not be framed.
245
+ */
246
+ export function pairingPageHtml(secret: string): string {
247
+ return `<!doctype html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="utf-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1">
252
+ <meta name="referrer" content="no-referrer">
253
+ <title>Pair the hosted dashboard</title>
254
+ </head>
255
+ <body>
256
+ <h1>Pair the hosted dashboard</h1>
257
+ <p>This browser is on the machine running the bridge. Pairing lets the hosted dashboard at ${HOSTED_DASHBOARD_URL} talk to this bridge from this browser profile.</p>
258
+ <p>Only pair a browser you use yourself. The link below hands the dashboard a secret; run <code>agent-comms pairing rotate</code> to revoke every pairing.</p>
259
+ <p><a rel="noopener noreferrer" href="${dashboardPairingUrl(secret)}">Pair this browser</a></p>
260
+ </body>
261
+ </html>
262
+ `;
263
+ }
264
+
265
+ /** Headers for the pairing page: not framed, not cached, not sent as a referrer, and not run as script. */
266
+ export const PAIRING_PAGE_HEADERS: Readonly<Record<string, string>> = {
267
+ "Content-Type": "text/html; charset=utf-8",
268
+ "Cache-Control": "no-store",
269
+ "Referrer-Policy": "no-referrer",
270
+ "X-Frame-Options": "DENY",
271
+ "Content-Security-Policy":
272
+ "default-src 'none'; style-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
273
+ };
package/src/cli-main.ts CHANGED
@@ -36,6 +36,7 @@ import { runCli } from "./bridges/user/cli.js";
36
36
  import { readHiddenLine, runAccountCommand } from "./account-cli.js";
37
37
  import { openMachineAccountStore } from "./account-join-cli.js";
38
38
  import { UnusableIdentityRecordError } from "./core/issuer-identity-file.js";
39
+ import { rotatePairingSecret } from "./bridges/user/web/web-pairing.js";
39
40
 
40
41
  // ---------------------------------------------------------------------------
41
42
  // Zod schemas for config files
@@ -264,9 +265,21 @@ export function main(options: Readonly<CliOptions>): void {
264
265
  );
265
266
  break;
266
267
  }
268
+ case "pairing": {
269
+ if (resolvedArgs[1] !== "rotate" || resolvedArgs.length > 2) {
270
+ console.error("Usage: agent-comms pairing rotate");
271
+ process.exit(1);
272
+ }
273
+ rotatePairingSecret();
274
+ console.log(
275
+ "The pairing secret has been replaced. A running bridge ends the hosted dashboard's open connections within a second, and every browser paired with the hosted dashboard must pair again, from each running bridge's /pair page.",
276
+ );
277
+ process.exit(0);
278
+ break;
279
+ }
267
280
  default:
268
281
  console.log(
269
- "Usage: agent-comms [setup|status|remove|bridge <id>|chat|send|dm|rooms|agents|read|account <export|import|invite|join>]",
282
+ "Usage: agent-comms [setup|status|remove|bridge <id>|chat|send|dm|rooms|agents|read|account <export|import|invite|join>|pairing rotate]",
270
283
  );
271
284
  console.log(
272
285
  "With no subcommand: setup when stdin is a terminal, the MCP server when it is not.",