machine-bridge-mcp 0.12.2 → 0.14.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.
@@ -9,9 +9,10 @@ const MAX_UI_ELEMENTS = 500;
9
9
  const MAX_UI_DEPTH = 12;
10
10
  const MAX_TEXT_CHARS = 4000;
11
11
  const APP_NAME_PATTERN = /^[^\0\r\n]{1,300}$/;
12
+ const DEFAULT_APPLICATION_CACHE_MS = 30_000;
12
13
 
13
14
  export class AppAutomationManager {
14
- constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null }) {
15
+ constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () => Date.now() }) {
15
16
  this.policy = policy || {};
16
17
  this.displayPath = displayPath;
17
18
  this.runProcess = runProcess;
@@ -20,6 +21,9 @@ export class AppAutomationManager {
20
21
  this.platform = platform;
21
22
  this.home = home;
22
23
  this.applicationRoots = applicationRoots;
24
+ this.applicationCacheMs = Math.max(0, Number(applicationCacheMs) || 0);
25
+ this.now = now;
26
+ this.applicationCache = null;
23
27
  }
24
28
 
25
29
  capabilities() {
@@ -57,6 +61,16 @@ export class AppAutomationManager {
57
61
  }
58
62
 
59
63
  async discoverApplications(context = {}) {
64
+ const now = this.now();
65
+ if (this.applicationCache && now - this.applicationCache.createdAt < this.applicationCacheMs) {
66
+ return this.applicationCache.result;
67
+ }
68
+ const result = await this.scanApplications(context);
69
+ this.applicationCache = { createdAt: this.now(), result };
70
+ return result;
71
+ }
72
+
73
+ async scanApplications(context = {}) {
60
74
  if (this.platform === "darwin") {
61
75
  return listMacApplications(this.applicationRoots || ["/Applications", "/System/Applications", join(this.home, "Applications")], context, this.throwIfCancelled);
62
76
  }
@@ -7,6 +7,11 @@ 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,11 @@ 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 = 2;
27
+ const EXTENSION_HANDSHAKE_MS = 3_000;
28
+ const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
29
+ "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
30
+ ]);
21
31
 
22
32
  export class BrowserBridgeManager {
23
33
  constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
@@ -30,10 +40,14 @@ export class BrowserBridgeManager {
30
40
  this.server = null;
31
41
  this.wss = null;
32
42
  this.socket = null;
43
+ this.pendingExtensionSocket = null;
33
44
  this.upstream = null;
34
45
  this.runtimeClients = new Set();
35
46
  this.proxyRoutes = new Map();
36
47
  this.proxyExtensionConnected = false;
48
+ this.extensionInfo = null;
49
+ this.proxyExtensionInfo = null;
50
+ this.proxyExtensionReloadRequired = false;
37
51
  this.recoveryTimer = null;
38
52
  this.stopping = false;
39
53
  this.pending = new Map();
@@ -47,16 +61,26 @@ export class BrowserBridgeManager {
47
61
  await this.ensureStarted(context);
48
62
  return {
49
63
  available: true,
50
- connected: this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected,
64
+ connected: this.extensionConnected(),
51
65
  broker_role: this.server ? "owner" : this.upstream?.readyState === 1 ? "client" : "unavailable",
52
66
  endpoint: `ws://127.0.0.1:${this.port}/extension`,
53
67
  pairing_url: `http://127.0.0.1:${this.port}/pair`,
54
68
  extension_path: this.extensionPath,
69
+ extension_protocol: this.extensionStatusInfo()?.protocol || null,
70
+ extension_version: this.extensionStatusInfo()?.version || "",
71
+ extension_capabilities: this.extensionStatusInfo()?.capabilities || [],
72
+ extension_reload_required: this.extensionReloadRequired(),
55
73
  supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
56
74
  controls_existing_profile: true,
57
75
  uses_existing_tabs_and_login_state: true,
58
76
  source_access: true,
77
+ semantic_snapshot_refs: true,
78
+ actionability_waits: true,
79
+ trusted_input: true,
80
+ input_modes: ["auto", "trusted", "dom"],
59
81
  complex_form_fill: true,
82
+ tab_management: true,
83
+ explicit_waits: true,
60
84
  screenshots: true,
61
85
  restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
62
86
  security: {
@@ -86,6 +110,7 @@ export class BrowserBridgeManager {
86
110
  "Open the browser extensions page and enable developer mode.",
87
111
  "Load the unpacked extension from extension_path once.",
88
112
  "Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
113
+ "After upgrades, reload the unpacked extension and accept any newly requested browser permission.",
89
114
  ],
90
115
  };
91
116
  }
@@ -98,6 +123,16 @@ export class BrowserBridgeManager {
98
123
  return response;
99
124
  }
100
125
 
126
+ async manageTabs(args = {}, context = {}) {
127
+ return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
128
+ }
129
+
130
+ async wait(args = {}, context = {}) {
131
+ const params = normalizeBrowserWait(args);
132
+ const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
133
+ return this.request("wait", params, conditionTimeoutSeconds + 5, context);
134
+ }
135
+
101
136
  async getSource(args = {}, context = {}) {
102
137
  const response = await this.request("get_source", {
103
138
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
@@ -130,17 +165,24 @@ export class BrowserBridgeManager {
130
165
  url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
131
166
  value: null,
132
167
  key: optionalString(args.key, "key", 100),
133
- waitFor: normalizeWait(args.wait_for),
168
+ waitFor: normalizeNavigationWait(args.wait_for),
169
+ inputMode: normalizeInputMode(args.input_mode),
170
+ elementTimeoutMs: clampInt(args.element_timeout_seconds, 10, 1, 60) * 1000,
134
171
  };
135
172
  if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
136
173
  if (action !== "press" && payload.key) throw new Error("key is only valid for press");
174
+ if (payload.inputMode === "trusted" && !["click", "double_click", "hover", "press", "type_text"].includes(action)) {
175
+ throw new Error("input_mode=trusted supports click, double_click, hover, press, and type_text only");
176
+ }
137
177
  if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
138
178
  if (args.value_resource !== undefined) {
139
179
  if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
140
180
  payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
141
181
  }
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);
182
+ if (payload.value !== null && !["fill", "select", "press", "type_text"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
183
+ const pageAction = !["navigate", "reload", "back", "forward"].includes(action);
184
+ const defaultTimeout = pageAction ? Math.min(120, Math.max(30, payload.elementTimeoutMs / 1000 + 5)) : 30;
185
+ const response = await this.request("action", payload, clampInt(args.timeout_seconds, defaultTimeout, 1, 120), context);
144
186
  return {
145
187
  ...response,
146
188
  value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
@@ -170,6 +212,7 @@ export class BrowserBridgeManager {
170
212
  }
171
213
  const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
172
214
  if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
215
+ if (value !== null && !["fill", "select"].includes(action)) throw new Error(`fields[${index}] value is not valid for action '${action}'`);
173
216
  fields.push({
174
217
  selector: normalizeBrowserSelector(input.selector, action),
175
218
  value,
@@ -177,14 +220,16 @@ export class BrowserBridgeManager {
177
220
  sensitive: input.sensitive === true || input.value_resource !== undefined,
178
221
  });
179
222
  }
223
+ const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
180
224
  return this.request("fill_form", {
181
225
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
182
226
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
183
227
  fields,
184
228
  submit: args.submit === true,
185
229
  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);
230
+ waitFor: normalizeNavigationWait(args.wait_for),
231
+ elementTimeoutMs: elementTimeoutSeconds * 1000,
232
+ }, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
188
233
  }
189
234
 
190
235
  async uploadFiles(args = {}, context = {}) {
@@ -208,12 +253,14 @@ export class BrowserBridgeManager {
208
253
  data: resource.buffer.toString("base64"),
209
254
  });
210
255
  }
256
+ const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
211
257
  const result = await this.request("upload_files", {
212
258
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
213
259
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
214
260
  selector: normalizeBrowserSelector(args.selector, "fill"),
215
261
  files,
216
- }, clampInt(args.timeout_seconds, 60, 1, 180), context);
262
+ elementTimeoutMs: elementTimeoutSeconds * 1000,
263
+ }, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
217
264
  return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
218
265
  }
219
266
 
@@ -244,7 +291,7 @@ export class BrowserBridgeManager {
244
291
  await this.ensureStarted(context);
245
292
  this.throwIfCancelled(context);
246
293
  const transport = this.server ? this.socket : this.upstream;
247
- const extensionConnected = this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected;
294
+ const extensionConnected = this.extensionConnected();
248
295
  if (!transport || transport.readyState !== 1 || !extensionConnected) {
249
296
  throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
250
297
  }
@@ -368,12 +415,15 @@ export class BrowserBridgeManager {
368
415
  }
369
416
  const message = parsed.message;
370
417
  if (!settled) {
371
- if (message.type !== "hello" || message.role !== "runtime") {
418
+ if (message.type !== "hello" || message.role !== "runtime" || message.protocol !== 1) {
372
419
  closeProtocolSocket(ws, 1002, "runtime hello required");
373
420
  return;
374
421
  }
375
422
  this.upstream = ws;
376
- this.proxyExtensionConnected = message.extension_connected === true;
423
+ const claimedExtension = message.extension_connected === true;
424
+ this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
425
+ this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
426
+ this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
377
427
  finish(true);
378
428
  return;
379
429
  }
@@ -384,6 +434,8 @@ export class BrowserBridgeManager {
384
434
  if (this.upstream === ws) {
385
435
  this.upstream = null;
386
436
  this.proxyExtensionConnected = false;
437
+ this.proxyExtensionInfo = null;
438
+ this.proxyExtensionReloadRequired = false;
387
439
  this.rejectPending("browser broker disconnected");
388
440
  if (settled) this.scheduleBrokerRecovery();
389
441
  }
@@ -406,36 +458,79 @@ export class BrowserBridgeManager {
406
458
  }
407
459
  });
408
460
  ws.on("error", () => {});
409
- safeSocketSend(ws, { type: "hello", role: "runtime", protocol: 1, extension_connected: this.socket?.readyState === 1 });
461
+ safeSocketSend(ws, {
462
+ type: "hello", role: "runtime", protocol: 1,
463
+ extension_connected: this.extensionConnected(),
464
+ extension_info: this.extensionStatusInfo(),
465
+ extension_reload_required: this.extensionReloadRequired(),
466
+ });
410
467
  return;
411
468
  }
412
- if (this.socket && this.socket.readyState === 1) this.socket.close(4001, "superseded");
413
- this.socket = ws;
469
+ if (this.pendingExtensionSocket && this.pendingExtensionSocket.readyState === 1) {
470
+ this.pendingExtensionSocket.close(4001, "superseded by a newer extension candidate");
471
+ }
472
+ this.pendingExtensionSocket = ws;
473
+ this.broadcastRuntimeStatus(this.extensionConnected());
474
+ ws.extensionReady = false;
475
+ ws.handshakeTimer = setTimeout(() => closeProtocolSocket(ws, 1002, "extension hello required; reload the extension"), EXTENSION_HANDSHAKE_MS);
476
+ ws.handshakeTimer.unref?.();
414
477
  ws.on("message", (data) => this.handleExtensionMessage(ws, data));
415
478
  ws.on("close", () => {
479
+ clearTimeout(ws.handshakeTimer);
480
+ if (this.pendingExtensionSocket === ws) {
481
+ this.pendingExtensionSocket = null;
482
+ this.broadcastRuntimeStatus(this.extensionConnected());
483
+ return;
484
+ }
416
485
  if (this.socket !== ws) return;
417
486
  this.socket = null;
487
+ this.extensionInfo = null;
418
488
  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
- }
489
+ this.rejectProxyRoutes("browser extension disconnected");
424
490
  this.broadcastRuntimeStatus(false);
425
491
  });
426
492
  ws.on("error", () => {});
427
- safeSocketSend(ws, { type: "hello", role: "extension", protocol: 1 });
428
- this.broadcastRuntimeStatus(true);
493
+ safeSocketSend(ws, { type: "hello", role: "extension", protocol: BROWSER_EXTENSION_PROTOCOL });
429
494
  }
430
495
 
431
496
  handleExtensionMessage(socket, data) {
432
- if (this.socket !== socket) return;
497
+ if (this.socket !== socket && this.pendingExtensionSocket !== socket) return;
433
498
  const parsed = parseBrowserSocketMessage(data);
434
499
  if (!parsed.ok) {
435
500
  closeProtocolSocket(socket, parsed.code, parsed.reason);
436
501
  return;
437
502
  }
438
503
  const message = parsed.message;
504
+ if (message.type === "hello") {
505
+ if (socket.extensionReady) {
506
+ closeProtocolSocket(socket, 1002, "duplicate extension hello");
507
+ return;
508
+ }
509
+ let info;
510
+ try { info = parseExtensionHello(message); }
511
+ catch (error) {
512
+ closeProtocolSocket(socket, 1002, String(error?.message || error).slice(0, 120));
513
+ return;
514
+ }
515
+ clearTimeout(socket.handshakeTimer);
516
+ socket.extensionReady = true;
517
+ if (this.pendingExtensionSocket === socket) this.pendingExtensionSocket = null;
518
+ const previous = this.socket;
519
+ this.socket = socket;
520
+ this.extensionInfo = info;
521
+ if (previous && previous !== socket) {
522
+ this.rejectPending("browser extension was replaced; retry the browser request");
523
+ this.rejectProxyRoutes("browser extension was replaced; retry the browser request");
524
+ if (previous.readyState === 1) previous.close(4001, "superseded");
525
+ }
526
+ this.broadcastRuntimeStatus(true);
527
+ return;
528
+ }
529
+ if (!socket.extensionReady) {
530
+ closeProtocolSocket(socket, 1002, "extension hello required; reload the extension");
531
+ return;
532
+ }
533
+ if (message.type === "ping") return;
439
534
  if (message.type !== "response" || typeof message.id !== "string") {
440
535
  closeProtocolSocket(socket, 1002, "invalid extension protocol message");
441
536
  return;
@@ -476,7 +571,7 @@ export class BrowserBridgeManager {
476
571
  closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
477
572
  return;
478
573
  }
479
- if (!this.socket || this.socket.readyState !== 1) {
574
+ if (!this.extensionConnected()) {
480
575
  safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
481
576
  return;
482
577
  }
@@ -511,7 +606,10 @@ export class BrowserBridgeManager {
511
606
 
512
607
  handleUpstreamMessage(message) {
513
608
  if (message.type === "status" && typeof message.extension_connected === "boolean") {
514
- this.proxyExtensionConnected = message.extension_connected;
609
+ const claimedExtension = message.extension_connected === true;
610
+ this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
611
+ this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
612
+ this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
515
613
  if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
516
614
  return true;
517
615
  }
@@ -526,7 +624,11 @@ export class BrowserBridgeManager {
526
624
  }
527
625
 
528
626
  broadcastRuntimeStatus(connected) {
529
- const message = { type: "status", extension_connected: connected };
627
+ const message = {
628
+ type: "status", extension_connected: connected,
629
+ extension_info: connected ? this.extensionStatusInfo() : null,
630
+ extension_reload_required: this.extensionReloadRequired(),
631
+ };
530
632
  for (const client of this.runtimeClients) safeSocketSend(client, message);
531
633
  }
532
634
 
@@ -552,7 +654,7 @@ export class BrowserBridgeManager {
552
654
  return;
553
655
  }
554
656
  if (url.pathname === "/healthz") {
555
- sendJson(response, { ok: true, connected: this.socket?.readyState === 1, broker: "machine-bridge-browser" });
657
+ sendJson(response, { ok: true, connected: this.extensionConnected(), broker: "machine-bridge-browser" });
556
658
  return;
557
659
  }
558
660
  if (url.pathname === "/pair") {
@@ -585,6 +687,14 @@ export class BrowserBridgeManager {
585
687
  this.pending.clear();
586
688
  }
587
689
 
690
+ rejectProxyRoutes(message) {
691
+ for (const [id, route] of this.proxyRoutes) {
692
+ clearTimeout(route.timeout);
693
+ safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
694
+ this.proxyRoutes.delete(id);
695
+ }
696
+ }
697
+
588
698
  stop() {
589
699
  this.stopping = true;
590
700
  clearTimeout(this.recoveryTimer);
@@ -592,9 +702,15 @@ export class BrowserBridgeManager {
592
702
  this.rejectPending("browser bridge stopped");
593
703
  try { this.upstream?.close(1001, "runtime stopped"); } catch {}
594
704
  try { this.socket?.close(1001, "runtime stopped"); } catch {}
705
+ try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
595
706
  for (const client of this.runtimeClients) try { client.close(1001, "runtime stopped"); } catch {}
596
707
  this.upstream = null;
597
708
  this.socket = null;
709
+ this.pendingExtensionSocket = null;
710
+ this.extensionInfo = null;
711
+ this.proxyExtensionConnected = false;
712
+ this.proxyExtensionInfo = null;
713
+ this.proxyExtensionReloadRequired = false;
598
714
  this.runtimeClients.clear();
599
715
  for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
600
716
  this.proxyRoutes.clear();
@@ -604,6 +720,22 @@ export class BrowserBridgeManager {
604
720
  this.server = null;
605
721
  }
606
722
 
723
+ extensionConnected() {
724
+ return this.server
725
+ ? this.socket?.readyState === 1 && Boolean(this.extensionInfo)
726
+ : this.upstream?.readyState === 1 && this.proxyExtensionConnected && Boolean(this.proxyExtensionInfo);
727
+ }
728
+
729
+ extensionStatusInfo() {
730
+ return this.server ? this.extensionInfo : this.proxyExtensionInfo;
731
+ }
732
+
733
+ extensionReloadRequired() {
734
+ return this.server
735
+ ? this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected()
736
+ : this.proxyExtensionReloadRequired;
737
+ }
738
+
607
739
  assertFull(tool) {
608
740
  if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
609
741
  throw new Error(`${tool} requires the canonical full profile`);
@@ -611,6 +743,35 @@ export class BrowserBridgeManager {
611
743
  }
612
744
  }
613
745
 
746
+ function normalizeCompatibleExtensionInfo(value) {
747
+ const info = normalizeExtensionInfo(value);
748
+ if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL) return null;
749
+ if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
750
+ return info;
751
+ }
752
+
753
+ function parseExtensionHello(message) {
754
+ if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
755
+ throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
756
+ }
757
+ const info = normalizeExtensionInfo(message);
758
+ if (!info) throw new Error("invalid extension hello; reload the extension");
759
+ const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
760
+ if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
761
+ return info;
762
+ }
763
+
764
+ function normalizeExtensionInfo(value) {
765
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
766
+ const protocol = Number(value.protocol);
767
+ const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
768
+ const capabilities = Array.isArray(value.capabilities)
769
+ ? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
770
+ : [];
771
+ if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
772
+ return { protocol, version, capabilities };
773
+ }
774
+
614
775
  async function loadOrCreatePairing(stateRoot) {
615
776
  if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
616
777
  assertStateMaintenanceAvailable(stateRoot);
@@ -697,42 +858,6 @@ function sendJson(response, value) {
697
858
  response.end(`${JSON.stringify(value)}\n`);
698
859
  }
699
860
 
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
861
  function normalizeUploadFilename(value, { derived = false } = {}) {
737
862
  let name = String(value || "");
738
863
  if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
@@ -760,13 +885,6 @@ function optionalStringArray(value, label, maxItems, maxLength) {
760
885
  });
761
886
  }
762
887
 
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
888
  function boundedValue(value, label) {
771
889
  const string = String(value);
772
890
  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 +896,3 @@ function validateResource(value) {
778
896
  if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
779
897
  return name;
780
898
  }
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
- }
@@ -0,0 +1,126 @@
1
+ const NAVIGATION_ACTIONS = new Set(["navigate", "reload", "back", "forward"]);
2
+ const PAGE_ACTIONS = new Set([
3
+ "click", "double_click", "hover", "fill", "type_text", "select", "check", "uncheck",
4
+ "focus", "press", "submit", "scroll_into_view",
5
+ ]);
6
+ const FORM_ACTIONS = new Set(["fill", "select", "check", "uncheck", "click"]);
7
+ const WAIT_STATES = new Set(["attached", "detached", "visible", "hidden", "enabled", "editable", "checked", "unchecked"]);
8
+ const LOAD_STATES = new Set(["domcontentloaded", "complete"]);
9
+ const INPUT_MODES = new Set(["auto", "trusted", "dom"]);
10
+
11
+ export function normalizeBrowserAction(value) {
12
+ const action = String(value || "").trim();
13
+ if (!NAVIGATION_ACTIONS.has(action) && !PAGE_ACTIONS.has(action)) throw new Error("unsupported browser action");
14
+ return action;
15
+ }
16
+
17
+ export function normalizeFormAction(value) {
18
+ const action = String(value || "").trim();
19
+ if (!FORM_ACTIONS.has(action)) throw new Error("unsupported form field action");
20
+ return action;
21
+ }
22
+
23
+ export function normalizeBrowserSelector(value, action = "") {
24
+ if (NAVIGATION_ACTIONS.has(action)) return null;
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
26
+ const allowed = new Set(["ref", "css", "id", "name", "label", "text", "role", "placeholder", "index"]);
27
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown selector field: ${key}`);
28
+ const output = {};
29
+ if (value.ref !== undefined) {
30
+ const ref = optionalString(value.ref, "selector.ref", 100);
31
+ if (ref) output.ref = ref;
32
+ }
33
+ for (const key of ["css", "id", "name", "label", "text", "role", "placeholder"]) {
34
+ if (value[key] === undefined) continue;
35
+ const normalized = optionalString(value[key], `selector.${key}`, 2000);
36
+ if (normalized) output[key] = normalized;
37
+ }
38
+ if (value.index !== undefined) output.index = optionalInteger(value.index, "selector.index", 0, 10000);
39
+ if (!Object.keys(output).length) throw new Error("selector requires at least one field");
40
+ if (output.ref && Object.keys(output).length !== 1) throw new Error("selector.ref cannot be combined with other selector fields");
41
+ return output;
42
+ }
43
+
44
+ export function normalizeInputMode(value) {
45
+ if (value === undefined || value === null || value === "") return "auto";
46
+ const mode = String(value);
47
+ if (!INPUT_MODES.has(mode)) throw new Error("input_mode must be auto, trusted, or dom");
48
+ return mode;
49
+ }
50
+
51
+ export function normalizeNavigationWait(value) {
52
+ if (value === undefined || value === null || value === "") return "none";
53
+ const wait = String(value);
54
+ if (!["none", ...LOAD_STATES].includes(wait)) throw new Error("wait_for must be none, domcontentloaded, or complete");
55
+ return wait;
56
+ }
57
+
58
+ export function normalizeBrowserWait(args = {}) {
59
+ const selector = args.selector === undefined ? null : normalizeBrowserSelector(args.selector, "wait");
60
+ const state = args.state === undefined || args.state === null || args.state === ""
61
+ ? (selector ? "visible" : "")
62
+ : String(args.state);
63
+ if (state && !WAIT_STATES.has(state)) throw new Error("state is not a supported browser wait state");
64
+ if (state && !selector) throw new Error("state requires selector");
65
+ const text = optionalString(args.text, "text", 4000);
66
+ const urlContains = optionalString(args.url_contains, "url_contains", 32768);
67
+ const loadState = args.load_state === undefined || args.load_state === null || args.load_state === "" ? "" : String(args.load_state);
68
+ if (loadState && !LOAD_STATES.has(loadState)) throw new Error("load_state must be domcontentloaded or complete");
69
+ if (!selector && !text && !urlContains && !loadState) throw new Error("browser_wait requires selector, text, url_contains, or load_state");
70
+ const timeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
71
+ return {
72
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
73
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
74
+ selector,
75
+ state,
76
+ text,
77
+ urlContains,
78
+ loadState,
79
+ timeoutMs: timeoutSeconds * 1000,
80
+ };
81
+ }
82
+
83
+ export function normalizeTabCommand(args = {}) {
84
+ const action = String(args.action || "").trim();
85
+ if (!["new", "activate", "close"].includes(action)) throw new Error("browser tab action must be new, activate, or close");
86
+ const tabId = optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER);
87
+ const rawUrl = optionalString(args.url, "url", 32768);
88
+ if (["activate", "close"].includes(action) && !tabId) throw new Error(`${action} requires tab_id`);
89
+ if (action !== "new" && rawUrl) throw new Error("url is only valid for new tabs");
90
+ return {
91
+ action,
92
+ tabId,
93
+ url: rawUrl ? validateNavigationUrl(rawUrl) : "",
94
+ active: args.active !== false,
95
+ };
96
+ }
97
+
98
+ export function validateNavigationUrl(value) {
99
+ if (!value) throw new Error("navigate requires url");
100
+ let parsed;
101
+ try { parsed = new URL(value); } catch { throw new Error("url must be an absolute URL"); }
102
+ if (!["http:", "https:", "file:"].includes(parsed.protocol)) throw new Error("url protocol must be http, https, or file");
103
+ return parsed.href;
104
+ }
105
+
106
+ export function optionalString(value, label, maxLength) {
107
+ if (value === undefined || value === null || value === "") return "";
108
+ if (typeof value !== "string" || value.includes("\0") || value.length > maxLength) {
109
+ throw new Error(`${label} must be a string of at most ${maxLength} characters without NUL bytes`);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ export function optionalInteger(value, label, min, max) {
115
+ if (value === undefined || value === null || value === "") return null;
116
+ const number = Number(value);
117
+ if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
118
+ return number;
119
+ }
120
+
121
+ export function clampInt(value, fallback, min, max) {
122
+ if (value === undefined || value === null || value === "") return fallback;
123
+ const number = Number(value);
124
+ if (!Number.isInteger(number) || number < min || number > max) throw new Error(`expected an integer from ${min} to ${max}`);
125
+ return number;
126
+ }