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