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.
- package/CHANGELOG.md +19 -0
- package/README.md +6 -6
- package/SECURITY.md +3 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/AUDIT.md +13 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/OPERATIONS.md +5 -4
- package/docs/TESTING.md +2 -2
- package/package.json +5 -3
- package/src/local/browser-bridge.mjs +64 -8
- package/src/local/cli.mjs +16 -2
- package/src/local/service.mjs +7 -3
- package/src/shared/tool-catalog.json +7 -7
- package/src/worker/index.ts +1 -1
package/docs/LOCAL_AUTOMATION.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
63
|
-
- `browser_inspect_page` returns a bounded
|
|
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
|
|
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,
|
|
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
|
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/local/service.mjs
CHANGED
|
@@ -465,11 +465,15 @@ async function statusWindowsTask() {
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
function windowsCommand(spec) {
|
|
468
|
-
return [spec.node, ...daemonArgs(spec)].map(
|
|
468
|
+
return [spec.node, ...daemonArgs(spec)].map(windowsCommandLineArgument).join(" ");
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
function
|
|
472
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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";
|