machine-bridge-mcp 0.14.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.
@@ -15,7 +15,7 @@ The extension works with the current profile and supports:
15
15
 
16
16
  - listing, creating, activating, and closing tabs;
17
17
  - reading the current serialized DOM HTML from the main frame or accessible subframes;
18
- - discovering interactive controls, stable per-document element references, actionability state, geometry, labels, roles, placeholders, and field metadata across accessible frames and open Shadow DOM roots;
18
+ - discovering interactive controls, bounded per-document element references, actionability state, geometry, labels, roles, placeholders, and field metadata across accessible frames and open Shadow DOM roots;
19
19
  - waiting for bounded combinations of URL, load state, page text, and element state;
20
20
  - structured navigation, click, double-click, hover, focus, fill, type-text, select, check, uncheck, key press, scroll-into-view, and form submission;
21
21
  - automatic existence/visibility/enabled/editable/stability/hit-target checks before actions, with fixed DevTools mouse/keyboard input for top-frame interactions and a controlled DOM fallback;
@@ -23,7 +23,7 @@ The extension works with the current profile and supports:
23
23
  - populating file inputs from registered local resource files;
24
24
  - visible-tab screenshots.
25
25
 
26
- Current support targets Chrome, Chromium, Microsoft Edge, Brave, Vivaldi, and compatible Chromium browsers. After upgrading Machine Bridge, reload the unpacked extension from the browser extensions page so its packaged scripts and permissions match the new runtime. Version 0.14.0 adds the `debugger` permission for fixed trusted-input commands; the extension attaches only for the action and detaches afterward. The broker requires a versioned extension hello with the expected capability set, keeps the last compatible connection active until a replacement completes its handshake, and reports `extension_reload_required` instead of silently routing new tools to stale extension code. Browser-internal pages, extension stores, some PDF/plugin viewers, cross-origin frames denied to the extension, and pages restricted by enterprise policy may not be scriptable.
26
+ Current support targets Chrome, Chromium, Microsoft Edge, Brave, Vivaldi, and compatible Chromium browsers. The extension must be loaded into the user profile to be controlled; it is not installed into Playwright or an isolated automation profile. After every Machine Bridge upgrade, reload the unpacked extension so packaged scripts, protocol, and permissions match the runtime. Version 0.15.0 requires protocol 3 with broker `hello_ack`; the badge and pairing page report success only after protocol/version/capability validation. Pairing material is persisted only after that acknowledgement, an invalid candidate cannot overwrite the previous pairing, and `browser status` reports the expected packaged build, authenticated connected build/protocol/capabilities, and `extension_reload_required`; exact version equality is required in addition to protocol and capabilities. Browser-internal pages, extension stores, some PDF/plugin viewers, inaccessible cross-origin frames, and pages restricted by enterprise policy remain unscriptable.
27
27
 
28
28
  ## One-time browser setup
29
29
 
@@ -55,17 +55,17 @@ The broker is machine-global rather than workspace-global. One local owner liste
55
55
 
56
56
  ## Browser tools
57
57
 
58
- - `browser_status` reports broker role, extension connection, supported operations, pairing URL, and extension path without returning the pairing token.
58
+ - `browser_status` reports broker role, authenticated extension protocol/version/capabilities, reload state, supported operations, pairing URL, and extension path without returning the pairing token.
59
59
  - `pair_browser_extension` opens the local pairing page and returns setup steps.
60
60
  - `browser_list_tabs` lists current tabs.
61
61
  - `browser_manage_tabs` creates, activates, or closes a selected tab.
62
- - `browser_get_source` returns bounded current DOM HTML for selected frames.
63
- - `browser_inspect_page` returns a bounded semantic snapshot. Each control includes a stable `ref` for the lifetime of that document/frame plus visibility, enabled/editable state, and viewport geometry.
62
+ - `browser_get_source` returns a bounded iterative DOM serialization. `max_bytes` is one aggregate request budget across at most 64 accessible frames; omitted frames and node/byte truncation are explicit.
63
+ - `browser_inspect_page` returns snapshot version 2 with one aggregate `max_elements` budget, at most 64 accessible frames, a 100,000-node per-frame scan ceiling, bounded page-controlled strings, URL-userinfo redaction, and explicit scan/frame truncation. Each control includes a reusable `ref` plus visibility, enabled/editable state, and viewport geometry. References are held in a 10,000-entry per-frame LRU; navigation, element replacement, or bounded eviction makes an older ref stale and requires a new inspection.
64
64
  - `browser_wait` waits until all supplied URL, load-state, page-text, and element-state conditions are true.
65
- - `browser_action` performs one structured navigation or page action. Navigation accepts absolute `http`, `https`, or `file` URLs; script/data schemes are rejected. Pointer and keyboard actions default to `input_mode: auto`, which attempts fixed trusted DevTools input in the top frame and falls back to DOM behavior. `trusted` forbids fallback; `dom` never attaches the debugger.
66
- - `browser_fill_form` fills up to 200 fields and can submit once.
65
+ - `browser_action` performs one structured navigation or page action. Navigation accepts absolute `http`, `https`, or `file` URLs; script/data schemes are rejected. Pointer and keyboard actions default to `input_mode: auto`, which uses fixed trusted DevTools input in the top frame and falls back to DOM only when no `Input` command has started. Once dispatch begins, failure has an unknown outcome and must be inspected before retrying; automatic replay is forbidden. `trusted` always forbids fallback; `dom` never attaches the debugger.
66
+ - `browser_fill_form` fills up to 200 fields and can submit once. If a later field fails, the error states how many earlier fields may already have changed without returning their values.
67
67
  - `browser_upload_files` sets a file input from up to eight registered local resources. Caller filenames must be safe single-component names; derived names have controls and separators removed. MIME overrides must be canonical media types.
68
- - `browser_screenshot` returns native MCP image content.
68
+ - `browser_screenshot` returns native MCP image content, temporarily activates the selected tab only when required, restores the previous active tab when safe, and never focuses another browser window.
69
69
 
70
70
  Selectors can use the `ref` returned by inspection, CSS, ID, field name, label text, visible text, ARIA/implicit role, placeholder, and a zero-based match index. `ref` cannot be combined with other fields and becomes stale after navigation or element replacement. Non-ref selectors that match multiple controls fail explicitly unless `index` disambiguates them. The fixed page module traverses open Shadow DOM roots; closed shadow roots remain inaccessible. For frame-specific work, inspect all frames first and then pass `frame_id` to an action. Trusted input currently targets the top frame because DevTools coordinates are top-level; use `input_mode: dom` for an explicitly selected subframe.
71
71
 
@@ -105,12 +105,12 @@ The implementation reduces avoidable risk as follows:
105
105
 
106
106
  - all browser/app tools are `full`-only;
107
107
  - no arbitrary evaluation, caller-provided script source, or caller-selected DevTools method is accepted; trusted input exposes only fixed `Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, and `Input.insertText` sequences;
108
- - loopback HTTP validates `Host`; extension WebSockets require a random bearer subprotocol and a `chrome-extension://` origin;
108
+ - loopback HTTP validates `Host`; extension WebSockets require a random bearer subprotocol and a canonical `chrome-extension://` origin whose host is a 32-character Chromium extension ID; pairing-page and broker ports must match;
109
109
  - runtime-to-broker connections require a separate authenticated subprotocol;
110
110
  - the pairing token is stored owner-only, embedded only in the non-cacheable local pairing page, and omitted from MCP results and logs;
111
111
  - an established extension pairing cannot be silently replaced by another localhost page; replacement requires clicking the extension action on the active pairing page;
112
112
  - arguments, form values, page source, screenshots, and results are not operational log data;
113
- - message, source, form-field, upload, traversal, concurrency, proxy-route, actionability, and timeout limits are enforced; ambiguous selectors, stale refs, hidden/disabled/edit-blocked controls, moving targets, and obscured pointer targets fail explicitly;
113
+ - message, aggregate source/element/frame, form-field, upload, DOM-node/text, concurrency, proxy-route, actionability, and request-deadline limits are enforced; ambiguous selectors, stale refs, hidden/disabled/edit-blocked controls, moving targets, and obscured pointer targets fail explicitly;
114
114
  - MCP cancellation clears local and broker pending state and propagates a cancellation signal to the extension; an action already delivered to a page or application may still have completed;
115
115
  - resource-backed text and files are not returned after injection.
116
116
 
@@ -44,14 +44,15 @@ machine-mcp browser setup
44
44
  machine-mcp browser status
45
45
  ```
46
46
 
47
- `browser setup` prints the unpacked-extension path and opens the local pairing page. Load that directory from the Chromium extensions page with Developer mode enabled. The pairing page contains the token only in non-cacheable loopback HTML; the CLI and MCP result do not print it. A first pairing is automatic. When local pairing state was reset or moved, click the extension icon on the active pairing page to authorize replacing the extension's previous broker configuration.
47
+ `browser setup` prints the unpacked-extension path and opens the local pairing page. Load that directory into the Chromium profile you actually use; it is not installed into Playwright or a separate automation profile. `browser status` prints the authenticated extension build/protocol and the existing-profile backend. The pairing page contains the token only in non-cacheable loopback HTML; the CLI and MCP result do not print it. A first pairing is automatic. When local pairing state was reset or moved, click the extension icon on the active pairing page to authorize replacing the extension's previous broker configuration.
48
48
 
49
49
  Diagnosis:
50
50
 
51
51
  | Result | Interpretation |
52
52
  |---|---|
53
53
  | broker not reachable | no full-profile runtime owns the machine broker, state is stale, or a local security product blocked loopback listening |
54
- | broker running, extension disconnected | extension not loaded/enabled, pairing not completed, browser profile not running, or MV3 worker reconnect failed |
54
+ | broker running, extension disconnected | extension not loaded/enabled in the intended Chromium profile, pairing not completed, browser profile not running, or MV3 worker reconnect failed |
55
+ | extension reload required | unpacked files, permissions, protocol, or capability set differ from the running extension; reload it from the extensions page and revisit the pairing page |
55
56
  | tab list works but page action fails | restricted browser-internal page, inaccessible frame, stale selector, page navigation, enterprise policy, or page script interference |
56
57
  | `frame_id` action fails | frame was replaced or is not accessible; inspect frames again |
57
58
  | resource-backed upload fails | resource unavailable/too large, input is not `type=file`, page replaced the input, or the site rejects the file |
@@ -100,7 +101,7 @@ Uninstall acquires a state-root `maintenance.lock` that blocks new profile/state
100
101
 
101
102
  ## Logs
102
103
 
103
- Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. A service-style `--daemon-only` start that finds the same workspace daemon already running is an idempotent no-op: it exits successfully without repeating warnings or readiness output; explicit policy/secret/change requests still report that changes were not applied. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
104
+ Windows Scheduled Task command lines use CRT-compatible quoting, including trailing backslashes and drive-root paths. Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. A service-style `--daemon-only` start that finds the same workspace daemon already running is an idempotent no-op: it exits successfully without repeating warnings or readiness output; explicit policy/secret/change requests still report that changes were not applied. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
104
105
 
105
106
  Logging is level-based:
106
107
 
@@ -199,7 +200,7 @@ Defense-in-depth limits include:
199
200
  - managed-job timeout: 1–3,600 seconds per step;
200
201
  - managed-job output: 64 KiB per stream and 256 KiB total captured across one job;
201
202
  - registered resources: 64, 1 MiB each, 8 MiB referenced per job;
202
- - browser source: 4 MiB; browser WebSocket messages: 8 MiB; pending browser requests: 32 per runtime;
203
+ - browser source: 4 MiB aggregate across at most 64 accessible frames; semantic inspection: 1,000 elements aggregate, 100,000 scanned nodes, 10,000 reusable refs, and 2 MiB bounded page-text search per frame; browser WebSocket messages: 8 MiB; pending browser requests: 32 per runtime;
203
204
  - browser forms: 200 fields, 128 KiB per text value, and 4 MiB aggregate text; upload: eight resources and 5 MiB total;
204
205
  - application Accessibility inspection: 500 elements and depth 12; action text: 4,000 characters;
205
206
  - job-scoped temporary files: 16 files, 512 KiB total content.
package/docs/TESTING.md CHANGED
@@ -52,7 +52,7 @@ The suite includes:
52
52
 
53
53
  ## Opt-in live desktop and browser validation
54
54
 
55
- The normal suite uses deterministic mocks for macOS Accessibility and an authenticated in-process extension peer for browser routing. Browser contract tests cover versioned extension handshakes, stale-extension rejection, compatible replacement without premature displacement, clean rejection of in-flight direct and proxied requests during replacement, keepalive handling, strict action/value validation, stable-ref validation, combined waits, tab commands, fixed trusted-input CDP sequences, attach/detach cleanup, semantic-ref stability, deterministic scrolling, obscured-target waiting, and stale-reference failure. Before a release that changes local UI automation, run the macOS live smoke test on a machine where the invoking Node/terminal process has Accessibility permission:
55
+ The normal suite uses deterministic mocks for macOS Accessibility and an authenticated in-process extension peer for browser routing. Browser contract tests cover acknowledged protocol readiness, provisional pairing commit/rollback boundaries, strict extension IDs and matching loopback ports, stale-extension rejection, compatible replacement without premature displacement, clean rejection of in-flight direct and proxied requests, keepalive handling, trusted-input replay prevention after partial dispatch, focus-safe screenshots, aggregate 64-frame/source/element budgets, bounded hostile DOM/text processing, contenteditable-secret suppression, partial-form failure reporting, stable refs, combined waits, and actionability failures. Before a release that changes local UI automation, run the macOS live smoke test on a machine where the invoking Node/terminal process has Accessibility permission:
56
56
 
57
57
  ```sh
58
58
  npm run app-automation:live-test
@@ -60,7 +60,7 @@ npm run app-automation:live-test
60
60
 
61
61
  It opens Calculator, activates it through the fixed JXA helper, verifies structured JSON output, inspects main-window Accessibility controls with menu recursion disabled, clicks the `One` control, and quits the application. It is intentionally excluded from CI because macOS TCC permission and a graphical login session are host state.
62
62
 
63
- For browser changes, perform an isolated-profile smoke test with the packaged unpacked extension: pair it to a temporary source runtime, navigate a local no-store page, inspect controls and reuse a returned `ref`, wait for an element state, fill multiple fields, exercise `input_mode: trusted` for click/key/text, verify the live DOM, create/activate/close a tab, and capture a screenshot. A Playwright persistent Chromium context is an acceptable harness because it loads the real MV3 service worker while keeping the user's daily browser profile untouched.
63
+ For deterministic release validation, perform an isolated-profile smoke test with the packaged unpacked extension; a Playwright persistent Chromium context is acceptable only as that isolated harness. When the requirement is specifically to prove control of the user's ordinary browser, the user must load the same unpacked directory into that known daily Chromium profile; status can verify the extension version/protocol and that Machine Bridge did not launch a browser, but cannot infer profile identity. Then use a localhost no-store fixture in a newly created tab. Do not enumerate, read, or mutate unrelated existing tabs. In both modes, inspect and reuse refs, exercise waits/forms/trusted input/open Shadow DOM/screenshots, verify final live DOM, and close the fixture tab.
64
64
 
65
65
  ## Additional release checks
66
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
44
44
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
45
45
  "syntax": "node scripts/syntax-check.mjs",
46
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run catalog:test && npm run agent-context:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
46
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
47
47
  "worker:dry-run": "wrangler deploy --dry-run",
48
48
  "worker:integration-test": "node tests/worker-integration-test.mjs",
49
49
  "release:check": "node scripts/github-release.mjs --check",
@@ -76,7 +76,9 @@
76
76
  "app-automation:live-test": "node tests/app-automation-test.mjs --live-macos",
77
77
  "browser-command:test": "node tests/browser-command-test.mjs",
78
78
  "browser-devtools-input:test": "node tests/browser-devtools-input-test.mjs",
79
- "browser-page-automation:test": "node tests/browser-page-automation-test.mjs"
79
+ "browser-page-automation:test": "node tests/browser-page-automation-test.mjs",
80
+ "browser-service-worker:test": "node tests/browser-service-worker-test.mjs",
81
+ "service-platform:test": "node tests/service-platform-test.mjs"
80
82
  },
81
83
  "dependencies": {
82
84
  "https-proxy-agent": "9.1.0",
@@ -1,6 +1,6 @@
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";
@@ -23,8 +23,11 @@ const MAX_FIELD_VALUE_CHARS = 128 * 1024;
23
23
  const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
24
24
  const PAIRING_FILE = "browser-bridge.json";
25
25
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
26
- const BROWSER_EXTENSION_PROTOCOL = 2;
26
+ const BROWSER_EXTENSION_PROTOCOL = 3;
27
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");
28
31
  const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
29
32
  "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
30
33
  ]);
@@ -48,6 +51,7 @@ export class BrowserBridgeManager {
48
51
  this.extensionInfo = null;
49
52
  this.proxyExtensionInfo = null;
50
53
  this.proxyExtensionReloadRequired = false;
54
+ this.extensionReloadRequiredFlag = false;
51
55
  this.recoveryTimer = null;
52
56
  this.stopping = false;
53
57
  this.pending = new Map();
@@ -66,12 +70,17 @@ export class BrowserBridgeManager {
66
70
  endpoint: `ws://127.0.0.1:${this.port}/extension`,
67
71
  pairing_url: `http://127.0.0.1:${this.port}/pair`,
68
72
  extension_path: this.extensionPath,
73
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
69
74
  extension_protocol: this.extensionStatusInfo()?.protocol || null,
70
75
  extension_version: this.extensionStatusInfo()?.version || "",
71
76
  extension_capabilities: this.extensionStatusInfo()?.capabilities || [],
72
77
  extension_reload_required: this.extensionReloadRequired(),
73
78
  supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
74
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,
75
84
  uses_existing_tabs_and_login_state: true,
76
85
  source_access: true,
77
86
  semantic_snapshot_refs: true,
@@ -363,7 +372,7 @@ export class BrowserBridgeManager {
363
372
  const protocol = String(request.headers["sec-websocket-protocol"] || "");
364
373
  const origin = String(request.headers.origin || "");
365
374
  let role = "";
366
- 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";
367
376
  if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
368
377
  if (!role) {
369
378
  socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
@@ -472,7 +481,10 @@ export class BrowserBridgeManager {
472
481
  this.pendingExtensionSocket = ws;
473
482
  this.broadcastRuntimeStatus(this.extensionConnected());
474
483
  ws.extensionReady = false;
475
- ws.handshakeTimer = setTimeout(() => closeProtocolSocket(ws, 1002, "extension hello required; reload the extension"), EXTENSION_HANDSHAKE_MS);
484
+ ws.handshakeTimer = setTimeout(() => {
485
+ this.markExtensionReloadRequired();
486
+ closeProtocolSocket(ws, 1002, "extension hello required; reload the extension");
487
+ }, EXTENSION_HANDSHAKE_MS);
476
488
  ws.handshakeTimer.unref?.();
477
489
  ws.on("message", (data) => this.handleExtensionMessage(ws, data));
478
490
  ws.on("close", () => {
@@ -497,22 +509,30 @@ export class BrowserBridgeManager {
497
509
  if (this.socket !== socket && this.pendingExtensionSocket !== socket) return;
498
510
  const parsed = parseBrowserSocketMessage(data);
499
511
  if (!parsed.ok) {
512
+ this.markExtensionReloadRequired();
500
513
  closeProtocolSocket(socket, parsed.code, parsed.reason);
501
514
  return;
502
515
  }
503
516
  const message = parsed.message;
504
517
  if (message.type === "hello") {
505
518
  if (socket.extensionReady) {
519
+ this.markExtensionReloadRequired();
506
520
  closeProtocolSocket(socket, 1002, "duplicate extension hello");
507
521
  return;
508
522
  }
509
523
  let info;
510
524
  try { info = parseExtensionHello(message); }
511
525
  catch (error) {
526
+ this.markExtensionReloadRequired();
512
527
  closeProtocolSocket(socket, 1002, String(error?.message || error).slice(0, 120));
513
528
  return;
514
529
  }
515
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;
516
536
  socket.extensionReady = true;
517
537
  if (this.pendingExtensionSocket === socket) this.pendingExtensionSocket = null;
518
538
  const previous = this.socket;
@@ -527,11 +547,13 @@ export class BrowserBridgeManager {
527
547
  return;
528
548
  }
529
549
  if (!socket.extensionReady) {
550
+ this.markExtensionReloadRequired();
530
551
  closeProtocolSocket(socket, 1002, "extension hello required; reload the extension");
531
552
  return;
532
553
  }
533
554
  if (message.type === "ping") return;
534
555
  if (message.type !== "response" || typeof message.id !== "string") {
556
+ this.markExtensionReloadRequired();
535
557
  closeProtocolSocket(socket, 1002, "invalid extension protocol message");
536
558
  return;
537
559
  }
@@ -654,7 +676,21 @@ export class BrowserBridgeManager {
654
676
  return;
655
677
  }
656
678
  if (url.pathname === "/healthz") {
657
- sendJson(response, { ok: true, connected: this.extensionConnected(), 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
+ });
658
694
  return;
659
695
  }
660
696
  if (url.pathname === "/pair") {
@@ -711,6 +747,7 @@ export class BrowserBridgeManager {
711
747
  this.proxyExtensionConnected = false;
712
748
  this.proxyExtensionInfo = null;
713
749
  this.proxyExtensionReloadRequired = false;
750
+ this.extensionReloadRequiredFlag = false;
714
751
  this.runtimeClients.clear();
715
752
  for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
716
753
  this.proxyRoutes.clear();
@@ -720,6 +757,11 @@ export class BrowserBridgeManager {
720
757
  this.server = null;
721
758
  }
722
759
 
760
+ markExtensionReloadRequired() {
761
+ this.extensionReloadRequiredFlag = true;
762
+ this.broadcastRuntimeStatus(this.extensionConnected());
763
+ }
764
+
723
765
  extensionConnected() {
724
766
  return this.server
725
767
  ? this.socket?.readyState === 1 && Boolean(this.extensionInfo)
@@ -732,7 +774,7 @@ export class BrowserBridgeManager {
732
774
 
733
775
  extensionReloadRequired() {
734
776
  return this.server
735
- ? this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected()
777
+ ? this.extensionReloadRequiredFlag || (this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected())
736
778
  : this.proxyExtensionReloadRequired;
737
779
  }
738
780
 
@@ -745,7 +787,7 @@ export class BrowserBridgeManager {
745
787
 
746
788
  function normalizeCompatibleExtensionInfo(value) {
747
789
  const info = normalizeExtensionInfo(value);
748
- if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL) return null;
790
+ if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
749
791
  if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
750
792
  return info;
751
793
  }
@@ -756,6 +798,7 @@ function parseExtensionHello(message) {
756
798
  }
757
799
  const info = normalizeExtensionInfo(message);
758
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`);
759
802
  const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
760
803
  if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
761
804
  return info;
@@ -809,7 +852,20 @@ async function savePairing(stateRoot, value) {
809
852
  }
810
853
 
811
854
  function pairingHtml(port, token) {
812
- 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;
813
869
  }
814
870
 
815
871
  function isAllowedLoopbackHost(host, port) {
package/src/local/cli.mjs CHANGED
@@ -753,13 +753,26 @@ async function browserCommand(args) {
753
753
  connected: health?.broker === "machine-bridge-browser" && health?.connected === true,
754
754
  extension_path: extensionPath,
755
755
  pairing_url: pairingUrl,
756
+ expected_extension_version: typeof health?.expected_extension_version === "string" ? health.expected_extension_version : "",
757
+ extension_protocol: Number.isInteger(health?.extension_protocol) ? health.extension_protocol : null,
758
+ extension_version: typeof health?.extension_version === "string" ? health.extension_version : "",
759
+ extension_capabilities: Array.isArray(health?.extension_capabilities) ? health.extension_capabilities : [],
760
+ extension_reload_required: health?.extension_reload_required === true,
761
+ controls_existing_profile: health?.controls_existing_profile === true,
762
+ controls_extension_profile: health?.controls_extension_profile === true,
763
+ machine_bridge_launches_browser: health?.machine_bridge_launches_browser === true,
764
+ profile_identity_verifiable: health?.profile_identity_verifiable === true,
756
765
  token_exposed: false,
757
766
  };
758
767
  if (action === "status") {
759
768
  if (args.json) console.log(JSON.stringify(result, null, 2));
760
769
  else {
761
770
  console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
762
- console.log(`Extension: ${result.connected ? "connected" : "not connected"}`);
771
+ console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
772
+ if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
773
+ if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
774
+ console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
775
+ if (result.controls_extension_profile) console.log(`Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.`);
763
776
  console.log(`Extension path: ${extensionPath}`);
764
777
  }
765
778
  return;
@@ -769,7 +782,8 @@ async function browserCommand(args) {
769
782
  if (args.json) console.log(JSON.stringify({ ...result, pairing_page_opened: true }, null, 2));
770
783
  else {
771
784
  console.log(`Extension path: ${extensionPath}`);
772
- console.log("Load this directory once from the Chromium extensions page with Developer mode enabled.");
785
+ console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
786
+ console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
773
787
  console.log(`Pairing page opened: ${pairingUrl}`);
774
788
  }
775
789
  }
@@ -465,11 +465,15 @@ async function statusWindowsTask() {
465
465
  }
466
466
 
467
467
  function windowsCommand(spec) {
468
- return [spec.node, ...daemonArgs(spec)].map(winQuote).join(" ");
468
+ return [spec.node, ...daemonArgs(spec)].map(windowsCommandLineArgument).join(" ");
469
469
  }
470
470
 
471
- function winQuote(value) {
472
- return `"${String(value).replaceAll('"', '\\"')}"`;
471
+ export function windowsCommandLineArgument(value) {
472
+ const text = String(value);
473
+ if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
474
+ const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
475
+ const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, (slashes) => `${slashes}${slashes}`);
476
+ return `"${escapedTrailingSlashes}"`;
473
477
  }
474
478
 
475
479
  export function systemdQuote(value) {
@@ -318,7 +318,7 @@
318
318
  {
319
319
  "name": "browser_status",
320
320
  "title": "Browser bridge status",
321
- "description": "Start or inspect the loopback browser-extension bridge used to automate the user's existing Chromium profile, windows, tabs, and login state.",
321
+ "description": "Start or inspect the loopback bridge for the Chromium profile where the extension is installed. Machine Bridge does not launch Playwright or another browser process, but cannot infer whether that profile is the user's daily or isolated profile; status reports authenticated extension version, protocol, capabilities, and reload state.",
322
322
  "availability": "full",
323
323
  "annotations": {
324
324
  "readOnlyHint": false,
@@ -334,7 +334,7 @@
334
334
  {
335
335
  "name": "pair_browser_extension",
336
336
  "title": "Pair browser extension",
337
- "description": "Open the local pairing page and return the packaged unpacked-extension path for one-time browser installation and pairing.",
337
+ "description": "Open the local pairing page and return the packaged unpacked-extension path for installation in the user's intended Chromium profile. Pairing is persisted only after an acknowledged version/capability handshake.",
338
338
  "availability": "full",
339
339
  "annotations": {
340
340
  "readOnlyHint": false,
@@ -437,7 +437,7 @@
437
437
  {
438
438
  "name": "browser_get_source",
439
439
  "title": "Read browser page source",
440
- "description": "Read bounded serialized current DOM HTML from the active or selected browser tab and optionally from all accessible frames.",
440
+ "description": "Read bounded serialized current DOM HTML from the active or selected browser tab. max_bytes is one aggregate budget across at most 64 accessible frames, with explicit frame, node, and byte truncation metadata.",
441
441
  "availability": "full",
442
442
  "annotations": {
443
443
  "readOnlyHint": true,
@@ -479,7 +479,7 @@
479
479
  {
480
480
  "name": "browser_inspect_page",
481
481
  "title": "Inspect browser page",
482
- "description": "Inspect bounded interactive DOM metadata across accessible frames for links, controls, form fields, accessible names, labels, and roles.",
482
+ "description": "Inspect a bounded snapshot-version-2 semantic representation across at most 64 accessible frames, with one aggregate element budget, bounded reusable refs, actionability state, bounded page-controlled metadata, and explicit scan/frame truncation.",
483
483
  "availability": "full",
484
484
  "annotations": {
485
485
  "readOnlyHint": true,
@@ -630,7 +630,7 @@
630
630
  {
631
631
  "name": "browser_action",
632
632
  "title": "Operate browser page",
633
- "description": "Perform one structured navigation or page action in the user's existing browser tab, with automatic actionability waits and optional fixed DevTools input, without accepting arbitrary JavaScript.",
633
+ "description": "Perform one structured navigation or page action in the user's existing browser tab without arbitrary JavaScript. Automatic trusted-input fallback occurs only before any DevTools Input command starts; ambiguous post-dispatch failures require inspection before retry.",
634
634
  "availability": "full",
635
635
  "annotations": {
636
636
  "readOnlyHint": false,
@@ -771,7 +771,7 @@
771
771
  {
772
772
  "name": "browser_fill_form",
773
773
  "title": "Fill complex browser form",
774
- "description": "Fill multiple fields and optionally submit a complex form in one structured browser transaction. Sensitive values can come from registered local resources and are not returned.",
774
+ "description": "Fill multiple fields and optionally submit a complex form. Sensitive values can come from registered local resources; errors identify possible earlier mutations without returning field values.",
775
775
  "availability": "full",
776
776
  "annotations": {
777
777
  "readOnlyHint": false,
@@ -950,7 +950,7 @@
950
950
  {
951
951
  "name": "browser_screenshot",
952
952
  "title": "Capture browser screenshot",
953
- "description": "Capture the visible area of the active or selected tab from the paired existing browser profile and return it as MCP image content.",
953
+ "description": "Capture the visible area of the active or selected tab from the paired existing browser profile, restore the previous active tab when safe, avoid focusing another window, and return native MCP image content.",
954
954
  "availability": "full",
955
955
  "annotations": {
956
956
  "readOnlyHint": false,
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
4
 
5
5
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.14.0";
6
+ const SERVER_VERSION = "0.15.0";
7
7
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
8
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
9
  const JSONRPC_VERSION = "2.0";