machine-bridge-mcp 0.8.2 → 0.10.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.
@@ -0,0 +1,757 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { closeSync, existsSync, fsyncSync, openSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { WebSocket, WebSocketServer } from "ws";
7
+ import { replaceFileSync } from "./atomic-fs.mjs";
8
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
+ import { ownerOnlyFile, packageRoot } from "./state.mjs";
10
+
11
+ const DEFAULT_PORT = 39393;
12
+ const MAX_PORT_ATTEMPTS = 10;
13
+ const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
14
+ const MAX_PENDING = 32;
15
+ const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
16
+ const MAX_FORM_FIELDS = 200;
17
+ const MAX_FIELD_VALUE_CHARS = 128 * 1024;
18
+ const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
19
+ const PAIRING_FILE = "browser-bridge.json";
20
+ const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
21
+
22
+ export class BrowserBridgeManager {
23
+ constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
24
+ this.policy = policy || {};
25
+ this.stateRoot = stateRoot ? resolve(stateRoot) : "";
26
+ this.runProcess = runProcess;
27
+ this.readResourceText = readResourceText;
28
+ this.readResourceBinary = readResourceBinary;
29
+ this.throwIfCancelled = throwIfCancelled;
30
+ this.server = null;
31
+ this.wss = null;
32
+ this.socket = null;
33
+ this.upstream = null;
34
+ this.runtimeClients = new Set();
35
+ this.proxyRoutes = new Map();
36
+ this.proxyExtensionConnected = false;
37
+ this.recoveryTimer = null;
38
+ this.stopping = false;
39
+ this.pending = new Map();
40
+ this.startPromise = null;
41
+ this.port = 0;
42
+ this.token = "";
43
+ this.extensionPath = resolve(packageRoot, "browser-extension");
44
+ }
45
+
46
+ async status(context = {}) {
47
+ await this.ensureStarted(context);
48
+ return {
49
+ available: true,
50
+ connected: this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected,
51
+ broker_role: this.server ? "owner" : this.upstream?.readyState === 1 ? "client" : "unavailable",
52
+ endpoint: `ws://127.0.0.1:${this.port}/extension`,
53
+ pairing_url: `http://127.0.0.1:${this.port}/pair`,
54
+ extension_path: this.extensionPath,
55
+ supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
56
+ controls_existing_profile: true,
57
+ uses_existing_tabs_and_login_state: true,
58
+ source_access: true,
59
+ complex_form_fill: true,
60
+ screenshots: true,
61
+ restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
62
+ security: {
63
+ loopback_only: true,
64
+ bearer_pairing_token: true,
65
+ arbitrary_extension_code_from_mcp: false,
66
+ resource_values_returned_to_model: false,
67
+ },
68
+ };
69
+ }
70
+
71
+ async pair(args = {}, context = {}) {
72
+ this.assertFull("pair_browser_extension");
73
+ const status = await this.status(context);
74
+ if (args.open !== false) {
75
+ const command = process.platform === "darwin"
76
+ ? { cmd: "open", argv: [status.pairing_url] }
77
+ : process.platform === "win32"
78
+ ? { cmd: "cmd.exe", argv: ["/d", "/s", "/c", "start", "", status.pairing_url] }
79
+ : { cmd: "xdg-open", argv: [status.pairing_url] };
80
+ await this.runProcess(command.cmd, command.argv, 30_000, false, 128 * 1024, context);
81
+ }
82
+ return {
83
+ ...status,
84
+ opened_pairing_page: args.open !== false,
85
+ setup_steps: [
86
+ "Open the browser extensions page and enable developer mode.",
87
+ "Load the unpacked extension from extension_path once.",
88
+ "Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
89
+ ],
90
+ };
91
+ }
92
+
93
+ async listTabs(args = {}, context = {}) {
94
+ const response = await this.request("list_tabs", {
95
+ currentWindow: args.current_window === true,
96
+ includePinned: args.include_pinned !== false,
97
+ }, clampInt(args.timeout_seconds, 30, 1, 120), context);
98
+ return response;
99
+ }
100
+
101
+ async getSource(args = {}, context = {}) {
102
+ const response = await this.request("get_source", {
103
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
104
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
105
+ allFrames: args.all_frames === true,
106
+ maxBytes: clampInt(args.max_bytes, 1024 * 1024, 1, MAX_SOURCE_BYTES),
107
+ }, clampInt(args.timeout_seconds, 30, 1, 120), context);
108
+ return response;
109
+ }
110
+
111
+ async inspectPage(args = {}, context = {}) {
112
+ return this.request("inspect_page", {
113
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
114
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
115
+ allFrames: args.all_frames !== false,
116
+ maxElements: clampInt(args.max_elements, 300, 1, 1000),
117
+ includeValues: args.include_values === true,
118
+ }, clampInt(args.timeout_seconds, 30, 1, 120), context);
119
+ }
120
+
121
+ async act(args = {}, context = {}) {
122
+ this.assertFull("browser_action");
123
+ const action = normalizeBrowserAction(args.action);
124
+ const rawUrl = optionalString(args.url, "url", 32768);
125
+ const payload = {
126
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
127
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
128
+ action,
129
+ selector: normalizeBrowserSelector(args.selector, action),
130
+ url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
131
+ value: null,
132
+ key: optionalString(args.key, "key", 100),
133
+ waitFor: normalizeWait(args.wait_for),
134
+ };
135
+ if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
136
+ if (action !== "press" && payload.key) throw new Error("key is only valid for press");
137
+ if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
138
+ if (args.value_resource !== undefined) {
139
+ if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
140
+ payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
141
+ }
142
+ if (payload.value !== null && !["fill", "select", "press"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
143
+ const response = await this.request("action", payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
144
+ return {
145
+ ...response,
146
+ value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
147
+ value_exposed: false,
148
+ };
149
+ }
150
+
151
+ async fillForm(args = {}, context = {}) {
152
+ this.assertFull("browser_fill_form");
153
+ if (!Array.isArray(args.fields) || !args.fields.length) throw new Error("fields must be a non-empty array");
154
+ if (args.fields.length > MAX_FORM_FIELDS) throw new Error(`fields contains more than ${MAX_FORM_FIELDS} entries`);
155
+ const fields = [];
156
+ let totalValueBytes = 0;
157
+ for (let index = 0; index < args.fields.length; index += 1) {
158
+ const input = args.fields[index];
159
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`fields[${index}] must be an object`);
160
+ const allowed = new Set(["selector", "value", "value_resource", "action", "sensitive"]);
161
+ for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`unknown fields[${index}] property: ${key}`);
162
+ let value = input.value === undefined ? null : boundedValue(input.value, `fields[${index}].value`);
163
+ if (input.value_resource !== undefined) {
164
+ if (value !== null) throw new Error(`fields[${index}] value and value_resource are mutually exclusive`);
165
+ value = boundedValue(await this.readResourceText(validateResource(input.value_resource)), `fields[${index}].value_resource`);
166
+ }
167
+ if (value !== null) {
168
+ totalValueBytes += Buffer.byteLength(value);
169
+ if (totalValueBytes > MAX_FORM_VALUE_BYTES) throw new Error("form field values exceed 4 MiB total");
170
+ }
171
+ const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
172
+ if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
173
+ fields.push({
174
+ selector: normalizeBrowserSelector(input.selector, action),
175
+ value,
176
+ action,
177
+ sensitive: input.sensitive === true || input.value_resource !== undefined,
178
+ });
179
+ }
180
+ return this.request("fill_form", {
181
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
182
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
183
+ fields,
184
+ submit: args.submit === true,
185
+ submitSelector: args.submit_selector ? normalizeBrowserSelector(args.submit_selector, "click") : null,
186
+ waitFor: normalizeWait(args.wait_for),
187
+ }, clampInt(args.timeout_seconds, 60, 1, 180), context);
188
+ }
189
+
190
+ async uploadFiles(args = {}, context = {}) {
191
+ this.assertFull("browser_upload_files");
192
+ if (!Array.isArray(args.resources) || !args.resources.length || args.resources.length > 8) throw new Error("resources must contain 1 to 8 registered resource names");
193
+ const filenames = optionalStringArray(args.filenames, "filenames", 8, 255);
194
+ const mimeTypes = optionalStringArray(args.mime_types, "mime_types", 8, 200);
195
+ if (filenames.length > args.resources.length || mimeTypes.length > args.resources.length) throw new Error("filenames and mime_types cannot contain more entries than resources");
196
+ const files = [];
197
+ let total = 0;
198
+ for (const raw of args.resources) {
199
+ const name = validateResource(raw);
200
+ const resource = this.readResourceBinary(name);
201
+ total += resource.buffer.length;
202
+ if (total > 5 * 1024 * 1024) throw new Error("browser upload resources exceed 5 MiB total");
203
+ files.push({
204
+ filename: String(args.filenames?.[files.length] || resource.path.split(/[\\/]/).pop() || name).slice(0, 255),
205
+ mime: String(args.mime_types?.[files.length] || "application/octet-stream").slice(0, 200),
206
+ data: resource.buffer.toString("base64"),
207
+ });
208
+ }
209
+ const result = await this.request("upload_files", {
210
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
211
+ frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
212
+ selector: normalizeBrowserSelector(args.selector, "fill"),
213
+ files,
214
+ }, clampInt(args.timeout_seconds, 60, 1, 180), context);
215
+ return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
216
+ }
217
+
218
+ async screenshot(args = {}, context = {}) {
219
+ const result = await this.request("screenshot", {
220
+ tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
221
+ format: args.format === "jpeg" ? "jpeg" : "png",
222
+ quality: clampInt(args.quality, 90, 1, 100),
223
+ }, clampInt(args.timeout_seconds, 30, 1, 120), context);
224
+ const data = String(result.data || "");
225
+ const match = /^data:(image\/(?:png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(data);
226
+ if (!match) throw new Error("browser extension returned an invalid screenshot");
227
+ return {
228
+ $mcp: {
229
+ content: [{ type: "image", data: match[2], mimeType: match[1] }],
230
+ structuredContent: {
231
+ tab_id: result.tab_id,
232
+ url: result.url,
233
+ title: result.title,
234
+ mime_type: match[1],
235
+ },
236
+ },
237
+ };
238
+ }
239
+
240
+ async request(method, params, timeoutSeconds, context = {}) {
241
+ this.assertFull(method);
242
+ await this.ensureStarted(context);
243
+ this.throwIfCancelled(context);
244
+ const transport = this.server ? this.socket : this.upstream;
245
+ const extensionConnected = this.server ? this.socket?.readyState === 1 : this.proxyExtensionConnected;
246
+ if (!transport || transport.readyState !== 1 || !extensionConnected) {
247
+ throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
248
+ }
249
+ if (this.pending.size >= MAX_PENDING) throw new Error("too many concurrent browser requests");
250
+ const id = `browser_${randomBytes(18).toString("base64url")}`;
251
+ return new Promise((resolvePromise, rejectPromise) => {
252
+ const timeoutMs = timeoutSeconds * 1000;
253
+ const timeout = setTimeout(() => {
254
+ this.pending.delete(id);
255
+ try { transport.send(JSON.stringify({ type: "cancel", id })); } catch {}
256
+ rejectPromise(new Error(`browser request timed out after ${timeoutSeconds}s`));
257
+ }, timeoutMs);
258
+ timeout.unref?.();
259
+ this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timeout, callId: context.callId || "" });
260
+ try {
261
+ const message = JSON.stringify({ type: "request", id, method, params, timeout_ms: timeoutMs });
262
+ if (Buffer.byteLength(message) > MAX_BROWSER_MESSAGE_BYTES) throw new Error("browser request exceeds maximum message size");
263
+ transport.send(message);
264
+ } catch (error) {
265
+ clearTimeout(timeout);
266
+ this.pending.delete(id);
267
+ rejectPromise(error);
268
+ }
269
+ });
270
+ }
271
+
272
+ async ensureStarted(context = {}) {
273
+ this.throwIfCancelled(context);
274
+ this.stopping = false;
275
+ if (this.server || this.upstream?.readyState === 1) return;
276
+ if (!this.startPromise) this.startPromise = this.start();
277
+ try { await this.startPromise; } finally { this.startPromise = null; }
278
+ }
279
+
280
+ async start() {
281
+ const pairing = await loadOrCreatePairing(this.stateRoot);
282
+ this.token = pairing.token;
283
+ for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
284
+ const port = pairing.port + offset;
285
+ try {
286
+ await this.listen(port);
287
+ if (port !== pairing.port && this.stateRoot) await savePairing(this.stateRoot, { token: this.token, port });
288
+ return;
289
+ } catch (error) {
290
+ if (error?.code !== "EADDRINUSE") throw error;
291
+ if (await this.connectProxy(port)) {
292
+ this.port = port;
293
+ return;
294
+ }
295
+ if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
296
+ }
297
+ }
298
+ }
299
+
300
+ async listen(port) {
301
+ this.port = port;
302
+ const server = createServer((request, response) => this.handleHttp(request, response));
303
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_BROWSER_MESSAGE_BYTES });
304
+ server.on("upgrade", (request, socket, head) => {
305
+ try {
306
+ const host = String(request.headers.host || "");
307
+ if (!isAllowedLoopbackHost(host, port)) {
308
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
309
+ socket.destroy();
310
+ return;
311
+ }
312
+ const url = new URL(request.url || "/", `http://${host}`);
313
+ const protocol = String(request.headers["sec-websocket-protocol"] || "");
314
+ const origin = String(request.headers.origin || "");
315
+ let role = "";
316
+ if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && origin.startsWith("chrome-extension://")) role = "extension";
317
+ if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
318
+ if (!role) {
319
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
320
+ socket.destroy();
321
+ return;
322
+ }
323
+ wss.handleUpgrade(request, socket, head, (ws) => {
324
+ ws.bridgeRole = role;
325
+ wss.emit("connection", ws, request);
326
+ });
327
+ } catch {
328
+ socket.destroy();
329
+ }
330
+ });
331
+ wss.on("connection", (ws) => this.acceptSocket(ws, ws.bridgeRole));
332
+ await new Promise((resolvePromise, rejectPromise) => {
333
+ const onError = (error) => { cleanup(); try { wss.close(); } catch {} try { server.close(); } catch {} rejectPromise(error); };
334
+ const onListening = () => { cleanup(); resolvePromise(); };
335
+ const cleanup = () => { server.off("error", onError); server.off("listening", onListening); };
336
+ server.once("error", onError);
337
+ server.once("listening", onListening);
338
+ server.listen(port, "127.0.0.1");
339
+ });
340
+ this.server = server;
341
+ this.wss = wss;
342
+ }
343
+
344
+ async connectProxy(port) {
345
+ const url = `ws://127.0.0.1:${port}/runtime`;
346
+ return new Promise((resolvePromise) => {
347
+ let settled = false;
348
+ const ws = new WebSocket(url, [`mbm-runtime.${this.token}`], { maxPayload: MAX_BROWSER_MESSAGE_BYTES });
349
+ const timer = setTimeout(() => finish(false), 1500);
350
+ timer.unref?.();
351
+ const finish = (ok) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ clearTimeout(timer);
355
+ if (!ok) try { ws.close(); } catch {}
356
+ resolvePromise(ok);
357
+ };
358
+ ws.on("message", (data) => {
359
+ let message;
360
+ try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
361
+ if (!settled && message?.type === "hello" && message?.role === "runtime") {
362
+ this.upstream = ws;
363
+ this.proxyExtensionConnected = message.extension_connected === true;
364
+ finish(true);
365
+ return;
366
+ }
367
+ this.handleUpstreamMessage(message);
368
+ });
369
+ ws.once("error", () => finish(false));
370
+ ws.once("close", () => {
371
+ if (this.upstream === ws) {
372
+ this.upstream = null;
373
+ this.proxyExtensionConnected = false;
374
+ this.rejectPending("browser broker disconnected");
375
+ if (settled) this.scheduleBrokerRecovery();
376
+ }
377
+ finish(false);
378
+ });
379
+ });
380
+ }
381
+
382
+ acceptSocket(ws, role) {
383
+ if (role === "runtime") {
384
+ this.runtimeClients.add(ws);
385
+ ws.on("message", (data) => this.handleRuntimeClientMessage(ws, data));
386
+ ws.on("close", () => {
387
+ this.runtimeClients.delete(ws);
388
+ for (const [id, route] of this.proxyRoutes) {
389
+ if (route.socket !== ws) continue;
390
+ clearTimeout(route.timeout);
391
+ this.proxyRoutes.delete(id);
392
+ try { this.socket?.send(JSON.stringify({ type: "cancel", id })); } catch {}
393
+ }
394
+ });
395
+ ws.on("error", () => {});
396
+ safeSocketSend(ws, { type: "hello", role: "runtime", protocol: 1, extension_connected: this.socket?.readyState === 1 });
397
+ return;
398
+ }
399
+ if (this.socket && this.socket.readyState === 1) this.socket.close(4001, "superseded");
400
+ this.socket = ws;
401
+ ws.on("message", (data) => this.handleExtensionMessage(data));
402
+ ws.on("close", () => {
403
+ if (this.socket !== ws) return;
404
+ this.socket = null;
405
+ this.rejectPending("browser extension disconnected");
406
+ for (const [id, route] of this.proxyRoutes) {
407
+ clearTimeout(route.timeout);
408
+ try { route.socket.send(JSON.stringify({ type: "response", id: route.id, ok: false, error: "browser extension disconnected" })); } catch {}
409
+ this.proxyRoutes.delete(id);
410
+ }
411
+ this.broadcastRuntimeStatus(false);
412
+ });
413
+ ws.on("error", () => {});
414
+ safeSocketSend(ws, { type: "hello", role: "extension", protocol: 1 });
415
+ this.broadcastRuntimeStatus(true);
416
+ }
417
+
418
+ handleExtensionMessage(data) {
419
+ if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return;
420
+ let message;
421
+ try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
422
+ if (!message || message.type !== "response" || typeof message.id !== "string") return;
423
+ const pending = this.pending.get(message.id);
424
+ if (pending) {
425
+ clearTimeout(pending.timeout);
426
+ this.pending.delete(message.id);
427
+ if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
428
+ else pending.resolve(message.result);
429
+ return;
430
+ }
431
+ const route = this.proxyRoutes.get(message.id);
432
+ if (!route) return;
433
+ clearTimeout(route.timeout);
434
+ this.proxyRoutes.delete(message.id);
435
+ try { route.socket.send(JSON.stringify({ ...message, id: route.id })); } catch {}
436
+ }
437
+
438
+ handleRuntimeClientMessage(socket, data) {
439
+ if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return;
440
+ let message;
441
+ try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
442
+ if (message?.type === "ping") return;
443
+ if (message?.type === "cancel" && typeof message.id === "string") {
444
+ for (const [routedId, route] of this.proxyRoutes) {
445
+ if (route.socket !== socket || route.id !== message.id) continue;
446
+ clearTimeout(route.timeout);
447
+ this.proxyRoutes.delete(routedId);
448
+ try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
449
+ }
450
+ return;
451
+ }
452
+ if (!message || message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;
453
+ if (!this.socket || this.socket.readyState !== 1) {
454
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
455
+ return;
456
+ }
457
+ if (this.proxyRoutes.size >= MAX_PENDING * 4) {
458
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker is busy" });
459
+ return;
460
+ }
461
+ const routedId = `proxy_${randomBytes(18).toString("base64url")}`;
462
+ let timeoutMs;
463
+ try { timeoutMs = clampInt(message.timeout_ms, 30_000, 1_000, 185_000); }
464
+ catch {
465
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "invalid browser request timeout" });
466
+ return;
467
+ }
468
+ const timeout = setTimeout(() => {
469
+ const route = this.proxyRoutes.get(routedId);
470
+ if (!route) return;
471
+ this.proxyRoutes.delete(routedId);
472
+ try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
473
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
474
+ }, timeoutMs);
475
+ timeout.unref?.();
476
+ this.proxyRoutes.set(routedId, { socket, id: message.id, timeout });
477
+ try {
478
+ this.socket.send(JSON.stringify({ ...message, id: routedId }));
479
+ } catch {
480
+ clearTimeout(timeout);
481
+ this.proxyRoutes.delete(routedId);
482
+ safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension send failed" });
483
+ }
484
+ }
485
+
486
+ handleUpstreamMessage(message) {
487
+ if (!message || typeof message !== "object") return;
488
+ if (message.type === "status") {
489
+ this.proxyExtensionConnected = message.extension_connected === true;
490
+ if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
491
+ return;
492
+ }
493
+ if (message.type !== "response" || typeof message.id !== "string") return;
494
+ const pending = this.pending.get(message.id);
495
+ if (!pending) return;
496
+ clearTimeout(pending.timeout);
497
+ this.pending.delete(message.id);
498
+ if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
499
+ else pending.resolve(message.result);
500
+ }
501
+
502
+ broadcastRuntimeStatus(connected) {
503
+ const message = { type: "status", extension_connected: connected };
504
+ for (const client of this.runtimeClients) safeSocketSend(client, message);
505
+ }
506
+
507
+ scheduleBrokerRecovery() {
508
+ if (this.stopping || this.recoveryTimer) return;
509
+ this.recoveryTimer = setTimeout(() => {
510
+ this.recoveryTimer = null;
511
+ void this.ensureStarted().catch(() => this.scheduleBrokerRecovery());
512
+ }, 250);
513
+ this.recoveryTimer.unref?.();
514
+ }
515
+
516
+ handleHttp(request, response) {
517
+ const host = String(request.headers.host || "");
518
+ if (!isAllowedLoopbackHost(host, this.port)) {
519
+ response.writeHead(403, securityHeaders("text/plain; charset=utf-8"));
520
+ response.end("Forbidden\n");
521
+ return;
522
+ }
523
+ const url = new URL(request.url || "/", `http://${host}`);
524
+ if (request.method !== "GET") {
525
+ response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
526
+ return;
527
+ }
528
+ if (url.pathname === "/healthz") {
529
+ sendJson(response, { ok: true, connected: this.socket?.readyState === 1, broker: "machine-bridge-browser" });
530
+ return;
531
+ }
532
+ if (url.pathname === "/pair") {
533
+ const html = pairingHtml(this.port, this.token);
534
+ response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
535
+ response.end(html);
536
+ return;
537
+ }
538
+ response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
539
+ response.end("Not found\n");
540
+ }
541
+
542
+ cancelCall(callId) {
543
+ if (!callId) return;
544
+ const transport = this.server ? this.socket : this.upstream;
545
+ for (const [id, pending] of this.pending) {
546
+ if (pending.callId !== callId) continue;
547
+ clearTimeout(pending.timeout);
548
+ this.pending.delete(id);
549
+ try { transport?.send(JSON.stringify({ type: "cancel", id })); } catch {}
550
+ pending.reject(new Error("browser request cancelled; a user-visible action may already have completed"));
551
+ }
552
+ }
553
+
554
+ rejectPending(message) {
555
+ for (const pending of this.pending.values()) {
556
+ clearTimeout(pending.timeout);
557
+ pending.reject(new Error(message));
558
+ }
559
+ this.pending.clear();
560
+ }
561
+
562
+ stop() {
563
+ this.stopping = true;
564
+ clearTimeout(this.recoveryTimer);
565
+ this.recoveryTimer = null;
566
+ this.rejectPending("browser bridge stopped");
567
+ try { this.upstream?.close(1001, "runtime stopped"); } catch {}
568
+ try { this.socket?.close(1001, "runtime stopped"); } catch {}
569
+ for (const client of this.runtimeClients) try { client.close(1001, "runtime stopped"); } catch {}
570
+ this.upstream = null;
571
+ this.socket = null;
572
+ this.runtimeClients.clear();
573
+ for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
574
+ this.proxyRoutes.clear();
575
+ try { this.wss?.close(); } catch {}
576
+ try { this.server?.close(); } catch {}
577
+ this.wss = null;
578
+ this.server = null;
579
+ }
580
+
581
+ assertFull(tool) {
582
+ if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
583
+ throw new Error(`${tool} requires the canonical full profile`);
584
+ }
585
+ }
586
+ }
587
+
588
+ async function loadOrCreatePairing(stateRoot) {
589
+ if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
590
+ await mkdir(stateRoot, { recursive: true, mode: 0o700 });
591
+ const file = join(stateRoot, PAIRING_FILE);
592
+ for (let attempt = 0; attempt < 2; attempt += 1) {
593
+ if (existsSync(file)) {
594
+ ownerOnlyFile(file);
595
+ let parsed;
596
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); } catch { throw new Error("browser pairing state is not valid bounded JSON"); }
597
+ if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
598
+ throw new Error("browser pairing state is invalid");
599
+ }
600
+ return parsed;
601
+ }
602
+ const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
603
+ let fd;
604
+ try {
605
+ fd = openSync(file, "wx", 0o600);
606
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}
607
+ `, "utf8");
608
+ fsyncSync(fd);
609
+ closeSync(fd);
610
+ fd = undefined;
611
+ ownerOnlyFile(file);
612
+ return value;
613
+ } catch (error) {
614
+ if (fd !== undefined) try { closeSync(fd); } catch {}
615
+ if (error?.code !== "EEXIST") throw error;
616
+ }
617
+ }
618
+ throw new Error("browser pairing state could not be initialized");
619
+ }
620
+
621
+ async function savePairing(stateRoot, value) {
622
+ const file = join(stateRoot, PAIRING_FILE);
623
+ const temp = `${file}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
624
+ let fd;
625
+ try {
626
+ fd = openSync(temp, "wx", 0o600);
627
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}
628
+ `, "utf8");
629
+ fsyncSync(fd);
630
+ closeSync(fd);
631
+ fd = undefined;
632
+ replaceFileSync(temp, file);
633
+ ownerOnlyFile(file);
634
+ } catch (error) {
635
+ if (fd !== undefined) try { closeSync(fd); } catch {}
636
+ try { unlinkSync(temp); } catch {}
637
+ throw error;
638
+ }
639
+ }
640
+
641
+ function pairingHtml(port, token) {
642
+ 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>`;
643
+ }
644
+
645
+ function isAllowedLoopbackHost(host, port) {
646
+ const normalized = String(host || "").toLowerCase();
647
+ return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
648
+ }
649
+
650
+ function securityHeaders(contentType) {
651
+ return {
652
+ "content-type": contentType,
653
+ "cache-control": "no-store",
654
+ "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
655
+ "x-content-type-options": "nosniff",
656
+ "referrer-policy": "no-referrer",
657
+ };
658
+ }
659
+
660
+ function safeSocketSend(socket, value) {
661
+ if (!socket || socket.readyState !== 1) return false;
662
+ try {
663
+ socket.send(typeof value === "string" ? value : JSON.stringify(value));
664
+ return true;
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+
670
+ function sendJson(response, value) {
671
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
672
+ response.end(`${JSON.stringify(value)}\n`);
673
+ }
674
+
675
+ function normalizeBrowserAction(value) {
676
+ const action = String(value || "").trim();
677
+ if (!["navigate", "click", "fill", "select", "check", "uncheck", "focus", "press", "submit", "reload", "back", "forward"].includes(action)) {
678
+ throw new Error("unsupported browser action");
679
+ }
680
+ return action;
681
+ }
682
+
683
+ function normalizeFormAction(value) {
684
+ const action = String(value || "").trim();
685
+ if (!["fill", "select", "check", "uncheck", "click"].includes(action)) throw new Error("unsupported form field action");
686
+ return action;
687
+ }
688
+
689
+ function normalizeBrowserSelector(value, action) {
690
+ if (["navigate", "reload", "back", "forward"].includes(action)) return null;
691
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
692
+ const allowed = new Set(["css", "id", "name", "label", "text", "role", "placeholder", "index"]);
693
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown selector field: ${key}`);
694
+ const output = {};
695
+ for (const key of ["css", "id", "name", "label", "text", "role", "placeholder"]) {
696
+ if (value[key] !== undefined) output[key] = optionalString(value[key], `selector.${key}`, 2000);
697
+ }
698
+ if (value.index !== undefined) output.index = optionalInteger(value.index, "selector.index", 0, 10000);
699
+ if (!Object.keys(output).length) throw new Error("selector requires at least one field");
700
+ return output;
701
+ }
702
+
703
+ function validateNavigationUrl(value) {
704
+ if (!value) throw new Error("navigate requires url");
705
+ let parsed;
706
+ try { parsed = new URL(value); } catch { throw new Error("url must be an absolute URL"); }
707
+ if (!["http:", "https:", "file:"].includes(parsed.protocol)) throw new Error("url protocol must be http, https, or file");
708
+ return parsed.href;
709
+ }
710
+
711
+ function optionalStringArray(value, label, maxItems, maxLength) {
712
+ if (value === undefined || value === null) return [];
713
+ if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${label} must be an array with at most ${maxItems} entries`);
714
+ return value.map((item, index) => {
715
+ if (typeof item !== "string" || item.includes("\0") || !item.length || item.length > maxLength) throw new Error(`${label}[${index}] must be a non-empty string of at most ${maxLength} characters`);
716
+ return item;
717
+ });
718
+ }
719
+
720
+ function normalizeWait(value) {
721
+ if (value === undefined || value === null || value === "") return "none";
722
+ const wait = String(value);
723
+ if (!["none", "domcontentloaded", "complete"].includes(wait)) throw new Error("wait_for must be none, domcontentloaded, or complete");
724
+ return wait;
725
+ }
726
+
727
+ function boundedValue(value, label) {
728
+ const string = String(value);
729
+ if (string.includes("\0") || string.length > MAX_FIELD_VALUE_CHARS) throw new Error(`${label} exceeds the maximum length or contains a NUL byte`);
730
+ return string;
731
+ }
732
+
733
+ function validateResource(value) {
734
+ const name = String(value || "").trim();
735
+ if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
736
+ return name;
737
+ }
738
+
739
+ function optionalString(value, label, maxLength) {
740
+ if (value === undefined || value === null || value === "") return "";
741
+ 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`);
742
+ return value;
743
+ }
744
+
745
+ function optionalInteger(value, label, min, max) {
746
+ if (value === undefined || value === null || value === "") return null;
747
+ const number = Number(value);
748
+ if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
749
+ return number;
750
+ }
751
+
752
+ function clampInt(value, fallback, min, max) {
753
+ if (value === undefined || value === null || value === "") return fallback;
754
+ const number = Number(value);
755
+ if (!Number.isInteger(number) || number < min || number > max) throw new Error(`expected an integer from ${min} to ${max}`);
756
+ return number;
757
+ }