machine-bridge-mcp 1.2.7 → 1.2.9

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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +37 -19
  3. package/README.md +120 -410
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +13 -4
  7. package/docs/AUDIT.md +22 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/OVERVIEW.md +113 -0
  10. package/docs/PROJECT_STANDARDS.md +9 -5
  11. package/docs/RELEASING.md +78 -33
  12. package/docs/TESTING.md +14 -7
  13. package/docs/THREAT_MODEL.md +142 -0
  14. package/package.json +18 -6
  15. package/scripts/check-plan.mjs +91 -0
  16. package/scripts/coverage-check.mjs +22 -0
  17. package/scripts/github-push.mjs +49 -0
  18. package/scripts/github-release.mjs +15 -8
  19. package/scripts/local-release-acceptance.mjs +186 -0
  20. package/scripts/release-acceptance.mjs +181 -0
  21. package/scripts/release-impact-check.mjs +19 -3
  22. package/scripts/release-state.mjs +1 -1
  23. package/scripts/run-checks.mjs +29 -0
  24. package/scripts/start-release-candidate.mjs +113 -0
  25. package/src/local/agent-context-projection.mjs +158 -0
  26. package/src/local/agent-context.mjs +23 -332
  27. package/src/local/agent-skill-discovery.mjs +230 -0
  28. package/src/local/agent-text-file.mjs +41 -0
  29. package/src/local/browser-bridge-http.mjs +48 -0
  30. package/src/local/browser-bridge.mjs +48 -222
  31. package/src/local/browser-broker-routes.mjs +136 -0
  32. package/src/local/browser-broker-server.mjs +59 -0
  33. package/src/local/browser-request-registry.mjs +67 -0
  34. package/src/local/managed-job-lock.mjs +99 -0
  35. package/src/local/managed-job-projection.mjs +68 -0
  36. package/src/local/managed-job-runner.mjs +73 -0
  37. package/src/local/managed-job-storage.mjs +93 -0
  38. package/src/local/managed-jobs.mjs +12 -297
  39. package/src/local/runtime-paths.mjs +107 -0
  40. package/src/local/runtime-relay.mjs +73 -0
  41. package/src/local/runtime-tool-handlers.mjs +66 -0
  42. package/src/local/runtime.mjs +22 -204
  43. package/src/local/windows-launcher.mjs +57 -0
  44. package/src/local/windows-service.mjs +7 -56
  45. package/src/worker/index.ts +9 -118
  46. package/src/worker/mcp-jsonrpc.ts +94 -0
  47. package/src/worker/oauth-authorization-page.ts +70 -0
  48. package/src/worker/oauth-controller.ts +9 -58
  49. package/src/worker/websocket-protocol.ts +24 -0
  50. package/tsconfig.local.json +6 -1
@@ -0,0 +1,48 @@
1
+ import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
2
+ import { isAllowedLoopbackHost, pairingHtml, securityHeaders, sendJson } from "./browser-pairing-store.mjs";
3
+
4
+ export function handleBrowserBridgeHttp(request, response, {
5
+ port,
6
+ token,
7
+ extensionConnected,
8
+ extensionStatusInfo,
9
+ extensionReloadRequired,
10
+ }) {
11
+ const host = String(request.headers.host || "");
12
+ if (!isAllowedLoopbackHost(host, port)) {
13
+ response.writeHead(403, securityHeaders("text/plain; charset=utf-8"));
14
+ response.end("Forbidden\n");
15
+ return;
16
+ }
17
+ const url = new URL(request.url || "/", `http://${host}`);
18
+ if (request.method !== "GET") {
19
+ response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
20
+ return;
21
+ }
22
+ if (url.pathname === "/healthz") {
23
+ const extension = extensionStatusInfo();
24
+ sendJson(response, {
25
+ ok: true,
26
+ connected: extensionConnected(),
27
+ broker: "machine-bridge-browser",
28
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
29
+ extension_protocol: extension?.protocol || null,
30
+ extension_version: extension?.version || "",
31
+ extension_capabilities: extension?.capabilities || [],
32
+ extension_reload_required: extensionReloadRequired(),
33
+ controls_existing_profile: true,
34
+ controls_extension_profile: true,
35
+ machine_bridge_launches_browser: false,
36
+ profile_identity_verifiable: false,
37
+ });
38
+ return;
39
+ }
40
+ if (url.pathname === "/pair") {
41
+ const html = pairingHtml(port, token);
42
+ response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
43
+ response.end(html);
44
+ return;
45
+ }
46
+ response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
47
+ response.end("Not found\n");
48
+ }
@@ -1,20 +1,18 @@
1
- import { createServer } from "node:http";
2
- import { randomBytes } from "node:crypto";
3
1
  import { resolve } from "node:path";
4
- import { WebSocket, WebSocketServer } from "ws";
2
+ import { WebSocket } from "ws";
5
3
  import { createToolAuthorizer } from "./policy.mjs";
6
4
  import { assertStateMaintenanceAvailable, packageRoot } from "./state.mjs";
7
5
  import {
8
6
  BROWSER_EXTENSION_PROTOCOL, EXPECTED_EXTENSION_VERSION, MAX_BROWSER_MESSAGE_BYTES,
9
7
  closeProtocolSocket, normalizeCompatibleExtensionInfo, parseBrowserSocketMessage, parseExtensionHello, safeSocketSend,
10
8
  } from "./browser-extension-protocol.mjs";
11
- import {
12
- isAllowedExtensionOrigin, isAllowedLoopbackHost, loadOrCreatePairing,
13
- pairingHtml, savePairing, securityHeaders, sendJson,
14
- } from "./browser-pairing-store.mjs";
15
- import { clampInt } from "./browser-command.mjs";
9
+ import { loadOrCreatePairing, savePairing } from "./browser-pairing-store.mjs";
16
10
  import { BrowserOperationService } from "./browser-operation-service.mjs";
17
11
  import { classifyOperationalError } from "./log.mjs";
12
+ import { BrowserRequestRegistry } from "./browser-request-registry.mjs";
13
+ import { handleBrowserBridgeHttp } from "./browser-bridge-http.mjs";
14
+ import { BrowserBrokerRoutes } from "./browser-broker-routes.mjs";
15
+ import { startBrowserBrokerServer } from "./browser-broker-server.mjs";
18
16
 
19
17
  const MAX_PORT_ATTEMPTS = 10;
20
18
  const MAX_PENDING = 32;
@@ -35,8 +33,6 @@ export class BrowserBridgeManager {
35
33
  this.socket = null;
36
34
  this.pendingExtensionSocket = null;
37
35
  this.upstream = null;
38
- this.runtimeClients = new Set();
39
- this.proxyRoutes = new Map();
40
36
  this.proxyExtensionConnected = false;
41
37
  this.extensionInfo = null;
42
38
  this.proxyExtensionInfo = null;
@@ -44,7 +40,18 @@ export class BrowserBridgeManager {
44
40
  this.extensionReloadRequiredFlag = false;
45
41
  this.recoveryTimer = null;
46
42
  this.stopping = false;
47
- this.pending = new Map();
43
+ this.requestRegistry = new BrowserRequestRegistry({ maximum: MAX_PENDING });
44
+ this.pending = this.requestRegistry.pending;
45
+ this.brokerRoutes = new BrowserBrokerRoutes({
46
+ maximum: MAX_PENDING * 4,
47
+ getExtensionSocket: () => this.socket,
48
+ extensionConnected: () => this.extensionConnected(),
49
+ extensionStatusInfo: () => this.extensionStatusInfo(),
50
+ extensionReloadRequired: () => this.extensionReloadRequired(),
51
+ });
52
+ // Public aliases remain for diagnostics and compatibility with existing behavioral tests.
53
+ this.runtimeClients = this.brokerRoutes.clients;
54
+ this.proxyRoutes = this.brokerRoutes.routes;
48
55
  this.startPromise = null;
49
56
  this.startGeneration = 0;
50
57
  this.port = 0;
@@ -95,30 +102,15 @@ export class BrowserBridgeManager {
95
102
  await this.ensureStarted(context);
96
103
  this.throwIfCancelled(context);
97
104
  const transport = this.server ? this.socket : this.upstream;
98
- const extensionConnected = this.extensionConnected();
99
- if (!transport || transport.readyState !== 1 || !extensionConnected) {
105
+ if (!transport || transport.readyState !== 1 || !this.extensionConnected()) {
100
106
  throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
101
107
  }
102
- if (this.pending.size >= MAX_PENDING) throw new Error("too many concurrent browser requests");
103
- const id = `browser_${randomBytes(18).toString("base64url")}`;
104
- return new Promise((resolvePromise, rejectPromise) => {
105
- const timeoutMs = timeoutSeconds * 1000;
106
- const timeout = setTimeout(() => {
107
- this.pending.delete(id);
108
- try { transport.send(JSON.stringify({ type: "cancel", id })); } catch {}
109
- rejectPromise(new Error(`browser request timed out after ${timeoutSeconds}s`));
110
- }, timeoutMs);
111
- timeout.unref?.();
112
- this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timeout, callId: context.callId || "" });
113
- try {
114
- const message = JSON.stringify({ type: "request", id, method, params, timeout_ms: timeoutMs });
115
- if (Buffer.byteLength(message) > MAX_BROWSER_MESSAGE_BYTES) throw new Error("browser request exceeds maximum message size");
116
- transport.send(message);
117
- } catch (error) {
118
- clearTimeout(timeout);
119
- this.pending.delete(id);
120
- rejectPromise(error);
121
- }
108
+ return this.requestRegistry.request({
109
+ transport,
110
+ method,
111
+ params,
112
+ timeoutSeconds,
113
+ callId: context.callId || "",
122
114
  });
123
115
  }
124
116
 
@@ -182,43 +174,12 @@ export class BrowserBridgeManager {
182
174
 
183
175
  async listen(port) {
184
176
  this.port = port;
185
- const server = createServer((request, response) => this.handleHttp(request, response));
186
- const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_BROWSER_MESSAGE_BYTES });
187
- server.on("upgrade", (request, socket, head) => {
188
- try {
189
- const host = String(request.headers.host || "");
190
- if (!isAllowedLoopbackHost(host, port)) {
191
- socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
192
- socket.destroy();
193
- return;
194
- }
195
- const url = new URL(request.url || "/", `http://${host}`);
196
- const protocol = String(request.headers["sec-websocket-protocol"] || "");
197
- const origin = String(request.headers.origin || "");
198
- let role = "";
199
- if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && isAllowedExtensionOrigin(origin)) role = "extension";
200
- if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
201
- if (!role) {
202
- socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
203
- socket.destroy();
204
- return;
205
- }
206
- wss.handleUpgrade(request, socket, head, (ws) => {
207
- ws.bridgeRole = role;
208
- wss.emit("connection", ws, request);
209
- });
210
- } catch {
211
- socket.destroy();
212
- }
213
- });
214
- wss.on("connection", (ws) => this.acceptSocket(ws, ws.bridgeRole));
215
- await new Promise((resolvePromise, rejectPromise) => {
216
- const onError = (error) => { cleanup(); try { wss.close(); } catch {} try { server.close(); } catch {} rejectPromise(error); };
217
- const onListening = () => { cleanup(); resolvePromise(); };
218
- const cleanup = () => { server.off("error", onError); server.off("listening", onListening); };
219
- server.once("error", onError);
220
- server.once("listening", onListening);
221
- server.listen(port, "127.0.0.1");
177
+ const { server, wss } = await startBrowserBrokerServer({
178
+ port,
179
+ token: this.token,
180
+ maxPayload: MAX_BROWSER_MESSAGE_BYTES,
181
+ onHttp: (request, response) => this.handleHttp(request, response),
182
+ onSocket: (socket, role) => this.acceptSocket(socket, role),
222
183
  });
223
184
  this.server = server;
224
185
  this.wss = wss;
@@ -284,24 +245,7 @@ export class BrowserBridgeManager {
284
245
 
285
246
  acceptSocket(ws, role) {
286
247
  if (role === "runtime") {
287
- this.runtimeClients.add(ws);
288
- ws.on("message", (data) => this.handleRuntimeClientMessage(ws, data));
289
- ws.on("close", () => {
290
- this.runtimeClients.delete(ws);
291
- for (const [id, route] of this.proxyRoutes) {
292
- if (route.socket !== ws) continue;
293
- clearTimeout(route.timeout);
294
- this.proxyRoutes.delete(id);
295
- try { this.socket?.send(JSON.stringify({ type: "cancel", id })); } catch {}
296
- }
297
- });
298
- ws.on("error", () => {});
299
- safeSocketSend(ws, {
300
- type: "hello", role: "runtime", protocol: 1,
301
- extension_connected: this.extensionConnected(),
302
- extension_info: this.extensionStatusInfo(),
303
- extension_reload_required: this.extensionReloadRequired(),
304
- });
248
+ this.brokerRoutes.acceptClient(ws);
305
249
  return;
306
250
  }
307
251
  if (this.pendingExtensionSocket && this.pendingExtensionSocket.readyState === 1) {
@@ -386,73 +330,12 @@ export class BrowserBridgeManager {
386
330
  closeProtocolSocket(socket, 1002, "invalid extension protocol message");
387
331
  return;
388
332
  }
389
- const pending = this.pending.get(message.id);
390
- if (pending) {
391
- clearTimeout(pending.timeout);
392
- this.pending.delete(message.id);
393
- if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
394
- else pending.resolve(message.result);
395
- return;
396
- }
397
- const route = this.proxyRoutes.get(message.id);
398
- if (!route) return;
399
- clearTimeout(route.timeout);
400
- this.proxyRoutes.delete(message.id);
401
- try { route.socket.send(JSON.stringify({ ...message, id: route.id })); } catch {}
333
+ if (this.requestRegistry.settle(message)) return;
334
+ this.brokerRoutes.settleExtensionResponse(message);
402
335
  }
403
336
 
404
337
  handleRuntimeClientMessage(socket, data) {
405
- const parsed = parseBrowserSocketMessage(data);
406
- if (!parsed.ok) {
407
- closeProtocolSocket(socket, parsed.code, parsed.reason);
408
- return;
409
- }
410
- const message = parsed.message;
411
- if (message.type === "ping") return;
412
- if (message.type === "cancel" && typeof message.id === "string") {
413
- for (const [routedId, route] of this.proxyRoutes) {
414
- if (route.socket !== socket || route.id !== message.id) continue;
415
- clearTimeout(route.timeout);
416
- this.proxyRoutes.delete(routedId);
417
- try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
418
- }
419
- return;
420
- }
421
- if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
422
- closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
423
- return;
424
- }
425
- if (!this.extensionConnected()) {
426
- safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
427
- return;
428
- }
429
- if (this.proxyRoutes.size >= MAX_PENDING * 4) {
430
- safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker is busy" });
431
- return;
432
- }
433
- const routedId = `proxy_${randomBytes(18).toString("base64url")}`;
434
- let timeoutMs;
435
- try { timeoutMs = clampInt(message.timeout_ms, 30_000, 1_000, 185_000); }
436
- catch {
437
- safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "invalid browser request timeout" });
438
- return;
439
- }
440
- const timeout = setTimeout(() => {
441
- const route = this.proxyRoutes.get(routedId);
442
- if (!route) return;
443
- this.proxyRoutes.delete(routedId);
444
- safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
445
- try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
446
- }, timeoutMs);
447
- timeout.unref?.();
448
- this.proxyRoutes.set(routedId, { socket, id: message.id, timeout });
449
- try {
450
- this.socket.send(JSON.stringify({ ...message, id: routedId, timeout_ms: timeoutMs }));
451
- } catch {
452
- clearTimeout(timeout);
453
- this.proxyRoutes.delete(routedId);
454
- safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension send failed" });
455
- }
338
+ return this.brokerRoutes.handleClientMessage(socket, data);
456
339
  }
457
340
 
458
341
  handleUpstreamMessage(message) {
@@ -465,22 +348,12 @@ export class BrowserBridgeManager {
465
348
  return true;
466
349
  }
467
350
  if (message.type !== "response" || typeof message.id !== "string") return false;
468
- const pending = this.pending.get(message.id);
469
- if (!pending) return true;
470
- clearTimeout(pending.timeout);
471
- this.pending.delete(message.id);
472
- if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
473
- else pending.resolve(message.result);
351
+ this.requestRegistry.settle(message);
474
352
  return true;
475
353
  }
476
354
 
477
355
  broadcastRuntimeStatus(connected) {
478
- const message = {
479
- type: "status", extension_connected: connected,
480
- extension_info: connected ? this.extensionStatusInfo() : null,
481
- extension_reload_required: this.extensionReloadRequired(),
482
- };
483
- for (const client of this.runtimeClients) safeSocketSend(client, message);
356
+ this.brokerRoutes.broadcastStatus(connected);
484
357
  }
485
358
 
486
359
  scheduleBrokerRecovery() {
@@ -497,71 +370,26 @@ export class BrowserBridgeManager {
497
370
  }
498
371
 
499
372
  handleHttp(request, response) {
500
- const host = String(request.headers.host || "");
501
- if (!isAllowedLoopbackHost(host, this.port)) {
502
- response.writeHead(403, securityHeaders("text/plain; charset=utf-8"));
503
- response.end("Forbidden\n");
504
- return;
505
- }
506
- const url = new URL(request.url || "/", `http://${host}`);
507
- if (request.method !== "GET") {
508
- response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
509
- return;
510
- }
511
- if (url.pathname === "/healthz") {
512
- const extension = this.extensionStatusInfo();
513
- sendJson(response, {
514
- ok: true,
515
- connected: this.extensionConnected(),
516
- broker: "machine-bridge-browser",
517
- expected_extension_version: EXPECTED_EXTENSION_VERSION,
518
- extension_protocol: extension?.protocol || null,
519
- extension_version: extension?.version || "",
520
- extension_capabilities: extension?.capabilities || [],
521
- extension_reload_required: this.extensionReloadRequired(),
522
- controls_existing_profile: true,
523
- controls_extension_profile: true,
524
- machine_bridge_launches_browser: false,
525
- profile_identity_verifiable: false,
526
- });
527
- return;
528
- }
529
- if (url.pathname === "/pair") {
530
- const html = pairingHtml(this.port, this.token);
531
- response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
532
- response.end(html);
533
- return;
534
- }
535
- response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
536
- response.end("Not found\n");
373
+ return handleBrowserBridgeHttp(request, response, {
374
+ port: this.port,
375
+ token: this.token,
376
+ extensionConnected: () => this.extensionConnected(),
377
+ extensionStatusInfo: () => this.extensionStatusInfo(),
378
+ extensionReloadRequired: () => this.extensionReloadRequired(),
379
+ });
537
380
  }
538
381
 
539
382
  cancelCall(callId) {
540
- if (!callId) return;
541
383
  const transport = this.server ? this.socket : this.upstream;
542
- for (const [id, pending] of this.pending) {
543
- if (pending.callId !== callId) continue;
544
- clearTimeout(pending.timeout);
545
- this.pending.delete(id);
546
- try { transport?.send(JSON.stringify({ type: "cancel", id })); } catch {}
547
- pending.reject(new Error("browser request cancelled; a user-visible action may already have completed"));
548
- }
384
+ this.requestRegistry.cancelCall(callId, transport);
549
385
  }
550
386
 
551
387
  rejectPending(message) {
552
- for (const pending of this.pending.values()) {
553
- clearTimeout(pending.timeout);
554
- pending.reject(new Error(message));
555
- }
556
- this.pending.clear();
388
+ this.requestRegistry.rejectAll(message);
557
389
  }
558
390
 
559
391
  rejectProxyRoutes(message) {
560
- for (const [id, route] of this.proxyRoutes) {
561
- clearTimeout(route.timeout);
562
- safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
563
- this.proxyRoutes.delete(id);
564
- }
392
+ this.brokerRoutes.rejectAll(message);
565
393
  }
566
394
 
567
395
  stop() {
@@ -574,11 +402,10 @@ export class BrowserBridgeManager {
574
402
 
575
403
  closeBrokerTransports(message) {
576
404
  this.rejectPending(message);
577
- this.rejectProxyRoutes(message);
578
405
  try { this.upstream?.close(1001, "runtime stopped"); } catch {}
579
406
  try { this.socket?.close(1001, "runtime stopped"); } catch {}
580
407
  try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
581
- for (const client of this.runtimeClients) try { client.close(1001, "runtime stopped"); } catch {}
408
+ this.brokerRoutes.close(message);
582
409
  this.upstream = null;
583
410
  this.socket = null;
584
411
  this.pendingExtensionSocket = null;
@@ -587,7 +414,6 @@ export class BrowserBridgeManager {
587
414
  this.proxyExtensionInfo = null;
588
415
  this.proxyExtensionReloadRequired = false;
589
416
  this.extensionReloadRequiredFlag = false;
590
- this.runtimeClients.clear();
591
417
  try { this.wss?.close(); } catch {}
592
418
  try { this.server?.close(); } catch {}
593
419
  this.wss = null;
@@ -0,0 +1,136 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { clampInt } from "./browser-command.mjs";
3
+ import { closeProtocolSocket, parseBrowserSocketMessage, safeSocketSend } from "./browser-extension-protocol.mjs";
4
+
5
+ export class BrowserBrokerRoutes {
6
+ constructor({ maximum, getExtensionSocket, extensionConnected, extensionStatusInfo, extensionReloadRequired }) {
7
+ this.maximum = maximum;
8
+ this.getExtensionSocket = getExtensionSocket;
9
+ this.extensionConnected = extensionConnected;
10
+ this.extensionStatusInfo = extensionStatusInfo;
11
+ this.extensionReloadRequired = extensionReloadRequired;
12
+ this.clients = new Set();
13
+ this.routes = new Map();
14
+ }
15
+
16
+ acceptClient(socket) {
17
+ this.clients.add(socket);
18
+ socket.on("message", (data) => this.handleClientMessage(socket, data));
19
+ socket.on("close", () => this.removeClient(socket));
20
+ socket.on("error", () => {});
21
+ safeSocketSend(socket, {
22
+ type: "hello",
23
+ role: "runtime",
24
+ protocol: 1,
25
+ extension_connected: this.extensionConnected(),
26
+ extension_info: this.extensionStatusInfo(),
27
+ extension_reload_required: this.extensionReloadRequired(),
28
+ });
29
+ }
30
+
31
+ removeClient(socket) {
32
+ this.clients.delete(socket);
33
+ for (const [id, route] of this.routes) {
34
+ if (route.socket !== socket) continue;
35
+ clearTimeout(route.timeout);
36
+ this.routes.delete(id);
37
+ this.sendExtension({ type: "cancel", id });
38
+ }
39
+ }
40
+
41
+ handleClientMessage(socket, data) {
42
+ const parsed = parseBrowserSocketMessage(data);
43
+ if (!parsed.ok) {
44
+ closeProtocolSocket(socket, parsed.code, parsed.reason);
45
+ return;
46
+ }
47
+ const message = parsed.message;
48
+ if (message.type === "ping") return;
49
+ if (message.type === "cancel" && typeof message.id === "string") {
50
+ this.cancelClientRequest(socket, message.id);
51
+ return;
52
+ }
53
+ if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
54
+ closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
55
+ return;
56
+ }
57
+ if (!this.extensionConnected()) {
58
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
59
+ return;
60
+ }
61
+ if (this.routes.size >= this.maximum) {
62
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker is busy" });
63
+ return;
64
+ }
65
+ const routedId = `proxy_${randomBytes(18).toString("base64url")}`;
66
+ let timeoutMs;
67
+ try { timeoutMs = clampInt(message.timeout_ms, 30_000, 1_000, 185_000); }
68
+ catch {
69
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "invalid browser request timeout" });
70
+ return;
71
+ }
72
+ const timeout = setTimeout(() => {
73
+ const route = this.routes.get(routedId);
74
+ if (!route) return;
75
+ this.routes.delete(routedId);
76
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
77
+ this.sendExtension({ type: "cancel", id: routedId });
78
+ }, timeoutMs);
79
+ timeout.unref?.();
80
+ this.routes.set(routedId, { socket, id: message.id, timeout });
81
+ if (this.sendExtension({ ...message, id: routedId, timeout_ms: timeoutMs })) return;
82
+ clearTimeout(timeout);
83
+ this.routes.delete(routedId);
84
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension send failed" });
85
+ }
86
+
87
+ cancelClientRequest(socket, requestId) {
88
+ for (const [routedId, route] of this.routes) {
89
+ if (route.socket !== socket || route.id !== requestId) continue;
90
+ clearTimeout(route.timeout);
91
+ this.routes.delete(routedId);
92
+ this.sendExtension({ type: "cancel", id: routedId });
93
+ }
94
+ }
95
+
96
+ settleExtensionResponse(message) {
97
+ const route = this.routes.get(message.id);
98
+ if (!route) return false;
99
+ clearTimeout(route.timeout);
100
+ this.routes.delete(message.id);
101
+ safeSocketSend(route.socket, { ...message, id: route.id });
102
+ return true;
103
+ }
104
+
105
+ broadcastStatus(connected) {
106
+ const message = {
107
+ type: "status",
108
+ extension_connected: connected,
109
+ extension_info: connected ? this.extensionStatusInfo() : null,
110
+ extension_reload_required: this.extensionReloadRequired(),
111
+ };
112
+ for (const client of this.clients) safeSocketSend(client, message);
113
+ }
114
+
115
+ rejectAll(message) {
116
+ for (const [id, route] of this.routes) {
117
+ clearTimeout(route.timeout);
118
+ safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
119
+ this.routes.delete(id);
120
+ }
121
+ }
122
+
123
+ close(message) {
124
+ this.rejectAll(message);
125
+ for (const client of this.clients) {
126
+ try { client.close(1001, "runtime stopped"); } catch {}
127
+ }
128
+ this.clients.clear();
129
+ }
130
+
131
+ sendExtension(message) {
132
+ const socket = this.getExtensionSocket();
133
+ if (!socket || socket.readyState !== 1) return false;
134
+ return safeSocketSend(socket, message);
135
+ }
136
+ }
@@ -0,0 +1,59 @@
1
+ import { createServer } from "node:http";
2
+ import { WebSocketServer } from "ws";
3
+ import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-store.mjs";
4
+
5
+ export async function startBrowserBrokerServer({ port, token, maxPayload, onHttp, onSocket }) {
6
+ const server = createServer(onHttp);
7
+ const wss = new WebSocketServer({ noServer: true, maxPayload });
8
+ server.on("upgrade", (request, socket, head) => {
9
+ try {
10
+ const host = String(request.headers.host || "");
11
+ if (!isAllowedLoopbackHost(host, port)) {
12
+ rejectUpgrade(socket, "403 Forbidden");
13
+ return;
14
+ }
15
+ const url = new URL(request.url || "/", `http://${host}`);
16
+ const protocol = String(request.headers["sec-websocket-protocol"] || "");
17
+ const origin = String(request.headers.origin || "");
18
+ let role = "";
19
+ if (url.pathname === "/extension" && protocol === `mbm.${token}` && isAllowedExtensionOrigin(origin)) role = "extension";
20
+ if (url.pathname === "/runtime" && protocol === `mbm-runtime.${token}` && !origin) role = "runtime";
21
+ if (!role) {
22
+ rejectUpgrade(socket, "401 Unauthorized");
23
+ return;
24
+ }
25
+ wss.handleUpgrade(request, socket, head, (ws) => {
26
+ ws.bridgeRole = role;
27
+ wss.emit("connection", ws, request);
28
+ });
29
+ } catch {
30
+ socket.destroy();
31
+ }
32
+ });
33
+ wss.on("connection", (ws) => onSocket(ws, ws.bridgeRole));
34
+ await new Promise((resolvePromise, rejectPromise) => {
35
+ const cleanup = () => {
36
+ server.off("error", onError);
37
+ server.off("listening", onListening);
38
+ };
39
+ const onError = (error) => {
40
+ cleanup();
41
+ try { wss.close(); } catch {}
42
+ try { server.close(); } catch {}
43
+ rejectPromise(error);
44
+ };
45
+ const onListening = () => {
46
+ cleanup();
47
+ resolvePromise();
48
+ };
49
+ server.once("error", onError);
50
+ server.once("listening", onListening);
51
+ server.listen(port, "127.0.0.1");
52
+ });
53
+ return { server, wss };
54
+ }
55
+
56
+ function rejectUpgrade(socket, status) {
57
+ socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
58
+ socket.destroy();
59
+ }
@@ -0,0 +1,67 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { MAX_BROWSER_MESSAGE_BYTES } from "./browser-extension-protocol.mjs";
3
+
4
+ export class BrowserRequestRegistry {
5
+ constructor({ maximum = 32 } = {}) {
6
+ this.maximum = maximum;
7
+ this.pending = new Map();
8
+ }
9
+
10
+ request({ transport, method, params, timeoutSeconds, callId = "" }) {
11
+ if (this.pending.size >= this.maximum) throw new Error("too many concurrent browser requests");
12
+ const id = `browser_${randomBytes(18).toString("base64url")}`;
13
+ return new Promise((resolvePromise, rejectPromise) => {
14
+ const timeoutMs = timeoutSeconds * 1000;
15
+ const timeout = setTimeout(() => {
16
+ this.pending.delete(id);
17
+ sendCancelQuietly(transport, id);
18
+ rejectPromise(new Error(`browser request timed out after ${timeoutSeconds}s`));
19
+ }, timeoutMs);
20
+ timeout.unref?.();
21
+ this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timeout, callId });
22
+ try {
23
+ const message = JSON.stringify({ type: "request", id, method, params, timeout_ms: timeoutMs });
24
+ if (Buffer.byteLength(message) > MAX_BROWSER_MESSAGE_BYTES) throw new Error("browser request exceeds maximum message size");
25
+ transport.send(message);
26
+ } catch (error) {
27
+ clearTimeout(timeout);
28
+ this.pending.delete(id);
29
+ rejectPromise(error);
30
+ }
31
+ });
32
+ }
33
+
34
+ settle(message) {
35
+ if (message?.type !== "response" || typeof message.id !== "string") return false;
36
+ const pending = this.pending.get(message.id);
37
+ if (!pending) return false;
38
+ clearTimeout(pending.timeout);
39
+ this.pending.delete(message.id);
40
+ if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
41
+ else pending.resolve(message.result);
42
+ return true;
43
+ }
44
+
45
+ cancelCall(callId, transport) {
46
+ if (!callId) return;
47
+ for (const [id, pending] of this.pending) {
48
+ if (pending.callId !== callId) continue;
49
+ clearTimeout(pending.timeout);
50
+ this.pending.delete(id);
51
+ sendCancelQuietly(transport, id);
52
+ pending.reject(new Error("browser request cancelled; a user-visible action may already have completed"));
53
+ }
54
+ }
55
+
56
+ rejectAll(message) {
57
+ for (const pending of this.pending.values()) {
58
+ clearTimeout(pending.timeout);
59
+ pending.reject(new Error(message));
60
+ }
61
+ this.pending.clear();
62
+ }
63
+ }
64
+
65
+ function sendCancelQuietly(transport, id) {
66
+ try { transport?.send(JSON.stringify({ type: "cancel", id })); } catch {}
67
+ }