machine-bridge-mcp 0.13.0 → 0.15.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.
@@ -1,12 +1,17 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
7
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
8
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
9
  import { assertStateMaintenanceAvailable, ownerOnlyFile, packageRoot } from "./state.mjs";
10
+ import {
11
+ clampInt, normalizeBrowserAction, normalizeBrowserSelector, normalizeBrowserWait, normalizeFormAction,
12
+ normalizeInputMode, normalizeNavigationWait, normalizeTabCommand, optionalInteger, optionalString,
13
+ validateNavigationUrl,
14
+ } from "./browser-command.mjs";
10
15
 
11
16
  const DEFAULT_PORT = 39393;
12
17
  const MAX_PORT_ATTEMPTS = 10;
@@ -18,6 +23,14 @@ const MAX_FIELD_VALUE_CHARS = 128 * 1024;
18
23
  const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
19
24
  const PAIRING_FILE = "browser-bridge.json";
20
25
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
26
+ const BROWSER_EXTENSION_PROTOCOL = 3;
27
+ const EXTENSION_HANDSHAKE_MS = 3_000;
28
+ const EXTENSION_MANIFEST = JSON.parse(readFileSync(resolve(packageRoot, "browser-extension", "manifest.json"), "utf8"));
29
+ const EXPECTED_EXTENSION_VERSION = String(EXTENSION_MANIFEST.version_name || EXTENSION_MANIFEST.version || "");
30
+ if (!EXPECTED_EXTENSION_VERSION) throw new Error("packaged browser extension version is missing");
31
+ const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
32
+ "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
33
+ ]);
21
34
 
22
35
  export class BrowserBridgeManager {
23
36
  constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
@@ -30,10 +43,15 @@ export class BrowserBridgeManager {
30
43
  this.server = null;
31
44
  this.wss = null;
32
45
  this.socket = null;
46
+ this.pendingExtensionSocket = null;
33
47
  this.upstream = null;
34
48
  this.runtimeClients = new Set();
35
49
  this.proxyRoutes = new Map();
36
50
  this.proxyExtensionConnected = false;
51
+ this.extensionInfo = null;
52
+ this.proxyExtensionInfo = null;
53
+ this.proxyExtensionReloadRequired = false;
54
+ this.extensionReloadRequiredFlag = false;
37
55
  this.recoveryTimer = null;
38
56
  this.stopping = false;
39
57
  this.pending = new Map();
@@ -47,16 +65,31 @@ export class BrowserBridgeManager {
47
65
  await this.ensureStarted(context);
48
66
  return {
49
67
  available: true,
50
- connected: this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected,
68
+ connected: this.extensionConnected(),
51
69
  broker_role: this.server ? "owner" : this.upstream?.readyState === 1 ? "client" : "unavailable",
52
70
  endpoint: `ws://127.0.0.1:${this.port}/extension`,
53
71
  pairing_url: `http://127.0.0.1:${this.port}/pair`,
54
72
  extension_path: this.extensionPath,
73
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
74
+ extension_protocol: this.extensionStatusInfo()?.protocol || null,
75
+ extension_version: this.extensionStatusInfo()?.version || "",
76
+ extension_capabilities: this.extensionStatusInfo()?.capabilities || [],
77
+ extension_reload_required: this.extensionReloadRequired(),
55
78
  supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
56
79
  controls_existing_profile: true,
80
+ controls_extension_profile: true,
81
+ launches_browser_process: false,
82
+ launches_separate_automation_profile: false,
83
+ profile_identity_verifiable: false,
57
84
  uses_existing_tabs_and_login_state: true,
58
85
  source_access: true,
86
+ semantic_snapshot_refs: true,
87
+ actionability_waits: true,
88
+ trusted_input: true,
89
+ input_modes: ["auto", "trusted", "dom"],
59
90
  complex_form_fill: true,
91
+ tab_management: true,
92
+ explicit_waits: true,
60
93
  screenshots: true,
61
94
  restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
62
95
  security: {
@@ -86,6 +119,7 @@ export class BrowserBridgeManager {
86
119
  "Open the browser extensions page and enable developer mode.",
87
120
  "Load the unpacked extension from extension_path once.",
88
121
  "Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
122
+ "After upgrades, reload the unpacked extension and accept any newly requested browser permission.",
89
123
  ],
90
124
  };
91
125
  }
@@ -98,6 +132,16 @@ export class BrowserBridgeManager {
98
132
  return response;
99
133
  }
100
134
 
135
+ async manageTabs(args = {}, context = {}) {
136
+ return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
137
+ }
138
+
139
+ async wait(args = {}, context = {}) {
140
+ const params = normalizeBrowserWait(args);
141
+ const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
142
+ return this.request("wait", params, conditionTimeoutSeconds + 5, context);
143
+ }
144
+
101
145
  async getSource(args = {}, context = {}) {
102
146
  const response = await this.request("get_source", {
103
147
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
@@ -130,17 +174,24 @@ export class BrowserBridgeManager {
130
174
  url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
131
175
  value: null,
132
176
  key: optionalString(args.key, "key", 100),
133
- waitFor: normalizeWait(args.wait_for),
177
+ waitFor: normalizeNavigationWait(args.wait_for),
178
+ inputMode: normalizeInputMode(args.input_mode),
179
+ elementTimeoutMs: clampInt(args.element_timeout_seconds, 10, 1, 60) * 1000,
134
180
  };
135
181
  if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
136
182
  if (action !== "press" && payload.key) throw new Error("key is only valid for press");
183
+ if (payload.inputMode === "trusted" && !["click", "double_click", "hover", "press", "type_text"].includes(action)) {
184
+ throw new Error("input_mode=trusted supports click, double_click, hover, press, and type_text only");
185
+ }
137
186
  if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
138
187
  if (args.value_resource !== undefined) {
139
188
  if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
140
189
  payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
141
190
  }
142
- if (payload.value !== null && !["fill", "select", "press"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
143
- const response = await this.request("action", payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
191
+ if (payload.value !== null && !["fill", "select", "press", "type_text"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
192
+ const pageAction = !["navigate", "reload", "back", "forward"].includes(action);
193
+ const defaultTimeout = pageAction ? Math.min(120, Math.max(30, payload.elementTimeoutMs / 1000 + 5)) : 30;
194
+ const response = await this.request("action", payload, clampInt(args.timeout_seconds, defaultTimeout, 1, 120), context);
144
195
  return {
145
196
  ...response,
146
197
  value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
@@ -170,6 +221,7 @@ export class BrowserBridgeManager {
170
221
  }
171
222
  const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
172
223
  if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
224
+ if (value !== null && !["fill", "select"].includes(action)) throw new Error(`fields[${index}] value is not valid for action '${action}'`);
173
225
  fields.push({
174
226
  selector: normalizeBrowserSelector(input.selector, action),
175
227
  value,
@@ -177,14 +229,16 @@ export class BrowserBridgeManager {
177
229
  sensitive: input.sensitive === true || input.value_resource !== undefined,
178
230
  });
179
231
  }
232
+ const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
180
233
  return this.request("fill_form", {
181
234
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
182
235
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
183
236
  fields,
184
237
  submit: args.submit === true,
185
238
  submitSelector: args.submit_selector ? normalizeBrowserSelector(args.submit_selector, "click") : null,
186
- waitFor: normalizeWait(args.wait_for),
187
- }, clampInt(args.timeout_seconds, 60, 1, 180), context);
239
+ waitFor: normalizeNavigationWait(args.wait_for),
240
+ elementTimeoutMs: elementTimeoutSeconds * 1000,
241
+ }, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
188
242
  }
189
243
 
190
244
  async uploadFiles(args = {}, context = {}) {
@@ -208,12 +262,14 @@ export class BrowserBridgeManager {
208
262
  data: resource.buffer.toString("base64"),
209
263
  });
210
264
  }
265
+ const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
211
266
  const result = await this.request("upload_files", {
212
267
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
213
268
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
214
269
  selector: normalizeBrowserSelector(args.selector, "fill"),
215
270
  files,
216
- }, clampInt(args.timeout_seconds, 60, 1, 180), context);
271
+ elementTimeoutMs: elementTimeoutSeconds * 1000,
272
+ }, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
217
273
  return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
218
274
  }
219
275
 
@@ -244,7 +300,7 @@ export class BrowserBridgeManager {
244
300
  await this.ensureStarted(context);
245
301
  this.throwIfCancelled(context);
246
302
  const transport = this.server ? this.socket : this.upstream;
247
- const extensionConnected = this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected;
303
+ const extensionConnected = this.extensionConnected();
248
304
  if (!transport || transport.readyState !== 1 || !extensionConnected) {
249
305
  throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
250
306
  }
@@ -316,7 +372,7 @@ export class BrowserBridgeManager {
316
372
  const protocol = String(request.headers["sec-websocket-protocol"] || "");
317
373
  const origin = String(request.headers.origin || "");
318
374
  let role = "";
319
- if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && origin.startsWith("chrome-extension://")) role = "extension";
375
+ if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && isAllowedExtensionOrigin(origin)) role = "extension";
320
376
  if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
321
377
  if (!role) {
322
378
  socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
@@ -368,12 +424,15 @@ export class BrowserBridgeManager {
368
424
  }
369
425
  const message = parsed.message;
370
426
  if (!settled) {
371
- if (message.type !== "hello" || message.role !== "runtime") {
427
+ if (message.type !== "hello" || message.role !== "runtime" || message.protocol !== 1) {
372
428
  closeProtocolSocket(ws, 1002, "runtime hello required");
373
429
  return;
374
430
  }
375
431
  this.upstream = ws;
376
- this.proxyExtensionConnected = message.extension_connected === true;
432
+ const claimedExtension = message.extension_connected === true;
433
+ this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
434
+ this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
435
+ this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
377
436
  finish(true);
378
437
  return;
379
438
  }
@@ -384,6 +443,8 @@ export class BrowserBridgeManager {
384
443
  if (this.upstream === ws) {
385
444
  this.upstream = null;
386
445
  this.proxyExtensionConnected = false;
446
+ this.proxyExtensionInfo = null;
447
+ this.proxyExtensionReloadRequired = false;
387
448
  this.rejectPending("browser broker disconnected");
388
449
  if (settled) this.scheduleBrokerRecovery();
389
450
  }
@@ -406,37 +467,93 @@ export class BrowserBridgeManager {
406
467
  }
407
468
  });
408
469
  ws.on("error", () => {});
409
- safeSocketSend(ws, { type: "hello", role: "runtime", protocol: 1, extension_connected: this.socket?.readyState === 1 });
470
+ safeSocketSend(ws, {
471
+ type: "hello", role: "runtime", protocol: 1,
472
+ extension_connected: this.extensionConnected(),
473
+ extension_info: this.extensionStatusInfo(),
474
+ extension_reload_required: this.extensionReloadRequired(),
475
+ });
410
476
  return;
411
477
  }
412
- if (this.socket && this.socket.readyState === 1) this.socket.close(4001, "superseded");
413
- this.socket = ws;
478
+ if (this.pendingExtensionSocket && this.pendingExtensionSocket.readyState === 1) {
479
+ this.pendingExtensionSocket.close(4001, "superseded by a newer extension candidate");
480
+ }
481
+ this.pendingExtensionSocket = ws;
482
+ this.broadcastRuntimeStatus(this.extensionConnected());
483
+ ws.extensionReady = false;
484
+ ws.handshakeTimer = setTimeout(() => {
485
+ this.markExtensionReloadRequired();
486
+ closeProtocolSocket(ws, 1002, "extension hello required; reload the extension");
487
+ }, EXTENSION_HANDSHAKE_MS);
488
+ ws.handshakeTimer.unref?.();
414
489
  ws.on("message", (data) => this.handleExtensionMessage(ws, data));
415
490
  ws.on("close", () => {
491
+ clearTimeout(ws.handshakeTimer);
492
+ if (this.pendingExtensionSocket === ws) {
493
+ this.pendingExtensionSocket = null;
494
+ this.broadcastRuntimeStatus(this.extensionConnected());
495
+ return;
496
+ }
416
497
  if (this.socket !== ws) return;
417
498
  this.socket = null;
499
+ this.extensionInfo = null;
418
500
  this.rejectPending("browser extension disconnected");
419
- for (const [id, route] of this.proxyRoutes) {
420
- clearTimeout(route.timeout);
421
- try { route.socket.send(JSON.stringify({ type: "response", id: route.id, ok: false, error: "browser extension disconnected" })); } catch {}
422
- this.proxyRoutes.delete(id);
423
- }
501
+ this.rejectProxyRoutes("browser extension disconnected");
424
502
  this.broadcastRuntimeStatus(false);
425
503
  });
426
504
  ws.on("error", () => {});
427
- safeSocketSend(ws, { type: "hello", role: "extension", protocol: 1 });
428
- this.broadcastRuntimeStatus(true);
505
+ safeSocketSend(ws, { type: "hello", role: "extension", protocol: BROWSER_EXTENSION_PROTOCOL });
429
506
  }
430
507
 
431
508
  handleExtensionMessage(socket, data) {
432
- if (this.socket !== socket) return;
509
+ if (this.socket !== socket && this.pendingExtensionSocket !== socket) return;
433
510
  const parsed = parseBrowserSocketMessage(data);
434
511
  if (!parsed.ok) {
512
+ this.markExtensionReloadRequired();
435
513
  closeProtocolSocket(socket, parsed.code, parsed.reason);
436
514
  return;
437
515
  }
438
516
  const message = parsed.message;
517
+ if (message.type === "hello") {
518
+ if (socket.extensionReady) {
519
+ this.markExtensionReloadRequired();
520
+ closeProtocolSocket(socket, 1002, "duplicate extension hello");
521
+ return;
522
+ }
523
+ let info;
524
+ try { info = parseExtensionHello(message); }
525
+ catch (error) {
526
+ this.markExtensionReloadRequired();
527
+ closeProtocolSocket(socket, 1002, String(error?.message || error).slice(0, 120));
528
+ return;
529
+ }
530
+ clearTimeout(socket.handshakeTimer);
531
+ if (!safeSocketSend(socket, { type: "hello_ack", role: "extension", protocol: BROWSER_EXTENSION_PROTOCOL })) {
532
+ closeProtocolSocket(socket, 1011, "extension acknowledgement failed");
533
+ return;
534
+ }
535
+ this.extensionReloadRequiredFlag = false;
536
+ socket.extensionReady = true;
537
+ if (this.pendingExtensionSocket === socket) this.pendingExtensionSocket = null;
538
+ const previous = this.socket;
539
+ this.socket = socket;
540
+ this.extensionInfo = info;
541
+ if (previous && previous !== socket) {
542
+ this.rejectPending("browser extension was replaced; retry the browser request");
543
+ this.rejectProxyRoutes("browser extension was replaced; retry the browser request");
544
+ if (previous.readyState === 1) previous.close(4001, "superseded");
545
+ }
546
+ this.broadcastRuntimeStatus(true);
547
+ return;
548
+ }
549
+ if (!socket.extensionReady) {
550
+ this.markExtensionReloadRequired();
551
+ closeProtocolSocket(socket, 1002, "extension hello required; reload the extension");
552
+ return;
553
+ }
554
+ if (message.type === "ping") return;
439
555
  if (message.type !== "response" || typeof message.id !== "string") {
556
+ this.markExtensionReloadRequired();
440
557
  closeProtocolSocket(socket, 1002, "invalid extension protocol message");
441
558
  return;
442
559
  }
@@ -476,7 +593,7 @@ export class BrowserBridgeManager {
476
593
  closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
477
594
  return;
478
595
  }
479
- if (!this.socket || this.socket.readyState !== 1) {
596
+ if (!this.extensionConnected()) {
480
597
  safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
481
598
  return;
482
599
  }
@@ -511,7 +628,10 @@ export class BrowserBridgeManager {
511
628
 
512
629
  handleUpstreamMessage(message) {
513
630
  if (message.type === "status" && typeof message.extension_connected === "boolean") {
514
- this.proxyExtensionConnected = message.extension_connected;
631
+ const claimedExtension = message.extension_connected === true;
632
+ this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
633
+ this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
634
+ this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
515
635
  if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
516
636
  return true;
517
637
  }
@@ -526,7 +646,11 @@ export class BrowserBridgeManager {
526
646
  }
527
647
 
528
648
  broadcastRuntimeStatus(connected) {
529
- const message = { type: "status", extension_connected: connected };
649
+ const message = {
650
+ type: "status", extension_connected: connected,
651
+ extension_info: connected ? this.extensionStatusInfo() : null,
652
+ extension_reload_required: this.extensionReloadRequired(),
653
+ };
530
654
  for (const client of this.runtimeClients) safeSocketSend(client, message);
531
655
  }
532
656
 
@@ -552,7 +676,21 @@ export class BrowserBridgeManager {
552
676
  return;
553
677
  }
554
678
  if (url.pathname === "/healthz") {
555
- sendJson(response, { ok: true, connected: this.socket?.readyState === 1, broker: "machine-bridge-browser" });
679
+ const extension = this.extensionStatusInfo();
680
+ sendJson(response, {
681
+ ok: true,
682
+ connected: this.extensionConnected(),
683
+ broker: "machine-bridge-browser",
684
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
685
+ extension_protocol: extension?.protocol || null,
686
+ extension_version: extension?.version || "",
687
+ extension_capabilities: extension?.capabilities || [],
688
+ extension_reload_required: this.extensionReloadRequired(),
689
+ controls_existing_profile: true,
690
+ controls_extension_profile: true,
691
+ machine_bridge_launches_browser: false,
692
+ profile_identity_verifiable: false,
693
+ });
556
694
  return;
557
695
  }
558
696
  if (url.pathname === "/pair") {
@@ -585,6 +723,14 @@ export class BrowserBridgeManager {
585
723
  this.pending.clear();
586
724
  }
587
725
 
726
+ rejectProxyRoutes(message) {
727
+ for (const [id, route] of this.proxyRoutes) {
728
+ clearTimeout(route.timeout);
729
+ safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
730
+ this.proxyRoutes.delete(id);
731
+ }
732
+ }
733
+
588
734
  stop() {
589
735
  this.stopping = true;
590
736
  clearTimeout(this.recoveryTimer);
@@ -592,9 +738,16 @@ export class BrowserBridgeManager {
592
738
  this.rejectPending("browser bridge stopped");
593
739
  try { this.upstream?.close(1001, "runtime stopped"); } catch {}
594
740
  try { this.socket?.close(1001, "runtime stopped"); } catch {}
741
+ try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
595
742
  for (const client of this.runtimeClients) try { client.close(1001, "runtime stopped"); } catch {}
596
743
  this.upstream = null;
597
744
  this.socket = null;
745
+ this.pendingExtensionSocket = null;
746
+ this.extensionInfo = null;
747
+ this.proxyExtensionConnected = false;
748
+ this.proxyExtensionInfo = null;
749
+ this.proxyExtensionReloadRequired = false;
750
+ this.extensionReloadRequiredFlag = false;
598
751
  this.runtimeClients.clear();
599
752
  for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
600
753
  this.proxyRoutes.clear();
@@ -604,6 +757,27 @@ export class BrowserBridgeManager {
604
757
  this.server = null;
605
758
  }
606
759
 
760
+ markExtensionReloadRequired() {
761
+ this.extensionReloadRequiredFlag = true;
762
+ this.broadcastRuntimeStatus(this.extensionConnected());
763
+ }
764
+
765
+ extensionConnected() {
766
+ return this.server
767
+ ? this.socket?.readyState === 1 && Boolean(this.extensionInfo)
768
+ : this.upstream?.readyState === 1 && this.proxyExtensionConnected && Boolean(this.proxyExtensionInfo);
769
+ }
770
+
771
+ extensionStatusInfo() {
772
+ return this.server ? this.extensionInfo : this.proxyExtensionInfo;
773
+ }
774
+
775
+ extensionReloadRequired() {
776
+ return this.server
777
+ ? this.extensionReloadRequiredFlag || (this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected())
778
+ : this.proxyExtensionReloadRequired;
779
+ }
780
+
607
781
  assertFull(tool) {
608
782
  if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
609
783
  throw new Error(`${tool} requires the canonical full profile`);
@@ -611,6 +785,36 @@ export class BrowserBridgeManager {
611
785
  }
612
786
  }
613
787
 
788
+ function normalizeCompatibleExtensionInfo(value) {
789
+ const info = normalizeExtensionInfo(value);
790
+ if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
791
+ if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
792
+ return info;
793
+ }
794
+
795
+ function parseExtensionHello(message) {
796
+ if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
797
+ throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
798
+ }
799
+ const info = normalizeExtensionInfo(message);
800
+ if (!info) throw new Error("invalid extension hello; reload the extension");
801
+ if (info.version !== EXPECTED_EXTENSION_VERSION) throw new Error(`extension version mismatch; expected ${EXPECTED_EXTENSION_VERSION}; reload the extension`);
802
+ const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
803
+ if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
804
+ return info;
805
+ }
806
+
807
+ function normalizeExtensionInfo(value) {
808
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
809
+ const protocol = Number(value.protocol);
810
+ const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
811
+ const capabilities = Array.isArray(value.capabilities)
812
+ ? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
813
+ : [];
814
+ if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
815
+ return { protocol, version, capabilities };
816
+ }
817
+
614
818
  async function loadOrCreatePairing(stateRoot) {
615
819
  if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
616
820
  assertStateMaintenanceAvailable(stateRoot);
@@ -648,7 +852,20 @@ async function savePairing(stateRoot, value) {
648
852
  }
649
853
 
650
854
  function pairingHtml(port, token) {
651
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="machine-bridge-browser-pair" content="1"><meta name="machine-bridge-browser-port" content="${port}"><meta name="machine-bridge-browser-token" content="${token}"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Machine Bridge browser pairing</title></head><body><h1>Machine Bridge browser pairing</h1><p>The installed extension reads pairing material from this loopback-only page and stores it in browser-local extension storage. It is not sent to any website.</p><p id="status">Waiting for the Machine Bridge extension.</p></body></html>`;
855
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="machine-bridge-browser-pair" content="1"><meta name="machine-bridge-browser-port" content="${port}"><meta name="machine-bridge-browser-token" content="${token}"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Machine Bridge browser pairing</title></head><body><h1>Machine Bridge browser pairing</h1><p>Expected extension build: <strong>${EXPECTED_EXTENSION_VERSION}</strong>. Reload the unpacked extension after every Machine Bridge upgrade.</p><p>The installed extension reads pairing material from this loopback-only page and stores it in browser-local extension storage. It is not sent to any website.</p><p id="status">Waiting for the Machine Bridge extension.</p></body></html>`;
856
+ }
857
+
858
+ function isAllowedExtensionOrigin(origin) {
859
+ let parsed;
860
+ try { parsed = new URL(String(origin || "")); } catch { return false; }
861
+ return parsed.protocol === "chrome-extension:"
862
+ && /^[a-p]{32}$/.test(parsed.hostname)
863
+ && !parsed.username
864
+ && !parsed.password
865
+ && !parsed.port
866
+ && (parsed.pathname === "" || parsed.pathname === "/")
867
+ && !parsed.search
868
+ && !parsed.hash;
652
869
  }
653
870
 
654
871
  function isAllowedLoopbackHost(host, port) {
@@ -697,42 +914,6 @@ function sendJson(response, value) {
697
914
  response.end(`${JSON.stringify(value)}\n`);
698
915
  }
699
916
 
700
- function normalizeBrowserAction(value) {
701
- const action = String(value || "").trim();
702
- if (!["navigate", "click", "fill", "select", "check", "uncheck", "focus", "press", "submit", "reload", "back", "forward"].includes(action)) {
703
- throw new Error("unsupported browser action");
704
- }
705
- return action;
706
- }
707
-
708
- function normalizeFormAction(value) {
709
- const action = String(value || "").trim();
710
- if (!["fill", "select", "check", "uncheck", "click"].includes(action)) throw new Error("unsupported form field action");
711
- return action;
712
- }
713
-
714
- function normalizeBrowserSelector(value, action) {
715
- if (["navigate", "reload", "back", "forward"].includes(action)) return null;
716
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
717
- const allowed = new Set(["css", "id", "name", "label", "text", "role", "placeholder", "index"]);
718
- for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown selector field: ${key}`);
719
- const output = {};
720
- for (const key of ["css", "id", "name", "label", "text", "role", "placeholder"]) {
721
- if (value[key] !== undefined) output[key] = optionalString(value[key], `selector.${key}`, 2000);
722
- }
723
- if (value.index !== undefined) output.index = optionalInteger(value.index, "selector.index", 0, 10000);
724
- if (!Object.keys(output).length) throw new Error("selector requires at least one field");
725
- return output;
726
- }
727
-
728
- function validateNavigationUrl(value) {
729
- if (!value) throw new Error("navigate requires url");
730
- let parsed;
731
- try { parsed = new URL(value); } catch { throw new Error("url must be an absolute URL"); }
732
- if (!["http:", "https:", "file:"].includes(parsed.protocol)) throw new Error("url protocol must be http, https, or file");
733
- return parsed.href;
734
- }
735
-
736
917
  function normalizeUploadFilename(value, { derived = false } = {}) {
737
918
  let name = String(value || "");
738
919
  if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
@@ -760,13 +941,6 @@ function optionalStringArray(value, label, maxItems, maxLength) {
760
941
  });
761
942
  }
762
943
 
763
- function normalizeWait(value) {
764
- if (value === undefined || value === null || value === "") return "none";
765
- const wait = String(value);
766
- if (!["none", "domcontentloaded", "complete"].includes(wait)) throw new Error("wait_for must be none, domcontentloaded, or complete");
767
- return wait;
768
- }
769
-
770
944
  function boundedValue(value, label) {
771
945
  const string = String(value);
772
946
  if (string.includes("\0") || string.length > MAX_FIELD_VALUE_CHARS) throw new Error(`${label} exceeds the maximum length or contains a NUL byte`);
@@ -778,23 +952,3 @@ function validateResource(value) {
778
952
  if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
779
953
  return name;
780
954
  }
781
-
782
- function optionalString(value, label, maxLength) {
783
- if (value === undefined || value === null || value === "") return "";
784
- if (typeof value !== "string" || value.includes("\0") || value.length > maxLength) throw new Error(`${label} must be a string of at most ${maxLength} characters without NUL bytes`);
785
- return value;
786
- }
787
-
788
- function optionalInteger(value, label, min, max) {
789
- if (value === undefined || value === null || value === "") return null;
790
- const number = Number(value);
791
- if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
792
- return number;
793
- }
794
-
795
- function clampInt(value, fallback, min, max) {
796
- if (value === undefined || value === null || value === "") return fallback;
797
- const number = Number(value);
798
- if (!Number.isInteger(number) || number < min || number > max) throw new Error(`expected an integer from ${min} to ${max}`);
799
- return number;
800
- }