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.
@@ -2,7 +2,7 @@
2
2
  const marker = document.querySelector('meta[name="machine-bridge-browser-pair"]');
3
3
  const portMeta = document.querySelector('meta[name="machine-bridge-browser-port"]');
4
4
  const tokenMeta = document.querySelector('meta[name="machine-bridge-browser-token"]');
5
- if (!marker || !portMeta || !tokenMeta) return;
5
+ if (marker?.content !== "1" || !portMeta || !tokenMeta) return;
6
6
  const token = tokenMeta.content;
7
7
  const port = Number(portMeta.content);
8
8
  if (!/^[A-Za-z0-9_-]{32,100}$/.test(token) || !Number.isInteger(port) || port < 1024 || port > 65535) return;
@@ -1,8 +1,12 @@
1
+ importScripts("devtools-input.js", "browser-operations.js");
2
+
1
3
  let socket = null;
2
4
  let reconnectTimer = null;
3
5
  let reconnectAttempt = 0;
4
6
  let keepaliveTimer = null;
5
7
  const MAX_RESULT_BYTES = 7 * 1024 * 1024;
8
+ const BROWSER_EXTENSION_PROTOCOL = 3;
9
+ const HANDSHAKE_TIMEOUT_MS = 3000;
6
10
  const activeRequests = new Map();
7
11
 
8
12
  chrome.runtime.onInstalled.addListener(() => { ensureReconnectAlarm(); void connectFromStorage(); });
@@ -20,7 +24,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
20
24
  chrome.action.onClicked.addListener((tab) => void handleActionClick(tab));
21
25
 
22
26
  async function handleActionClick(tab) {
23
- if (tab?.id && /^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) {
27
+ if (tab?.id && parsePairingPage(tab.url)) {
24
28
  await confirmRepairFromTab(tab);
25
29
  return;
26
30
  }
@@ -30,8 +34,28 @@ async function handleActionClick(tab) {
30
34
  }
31
35
 
32
36
  function pairingUrlFromEndpoint(endpoint) {
33
- const match = /^ws:\/\/127\.0\.0\.1:(\d+)\/extension$/.exec(String(endpoint || ""));
34
- return match ? `http://127.0.0.1:${match[1]}/pair` : "";
37
+ const parsed = parseBrokerEndpoint(endpoint);
38
+ return parsed ? `http://127.0.0.1:${parsed.port}/pair` : "";
39
+ }
40
+
41
+ function parsePairingPage(value) {
42
+ let parsed;
43
+ try { parsed = new URL(String(value || "")); } catch { return null; }
44
+ const port = Number(parsed.port);
45
+ if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1" || parsed.pathname !== "/pair"
46
+ || parsed.username || parsed.password || parsed.search || parsed.hash
47
+ || !Number.isInteger(port) || port < 1024 || port > 65535) return null;
48
+ return parsed;
49
+ }
50
+
51
+ function parseBrokerEndpoint(value) {
52
+ let parsed;
53
+ try { parsed = new URL(String(value || "")); } catch { return null; }
54
+ const port = Number(parsed.port);
55
+ if (parsed.protocol !== "ws:" || parsed.hostname !== "127.0.0.1" || parsed.pathname !== "/extension"
56
+ || parsed.username || parsed.password || parsed.search || parsed.hash
57
+ || !Number.isInteger(port) || port < 1024 || port > 65535) return null;
58
+ return parsed;
35
59
  }
36
60
 
37
61
  function setConnectionState(state) {
@@ -54,21 +78,38 @@ function ignoreOptionalPromise(value) {
54
78
  async function pairConfiguration(rawEndpoint, rawToken, { replace, senderUrl }) {
55
79
  const endpoint = String(rawEndpoint || "");
56
80
  const token = String(rawToken || "");
57
- if (!/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(senderUrl || ""))) return { ok: false, error: "invalid_pairing_page" };
58
- if (!/^ws:\/\/127\.0\.0\.1:\d+\/extension$/.test(endpoint) || !/^[A-Za-z0-9_-]{32,100}$/.test(token)) return { ok: false, error: "invalid_pairing_material" };
81
+ const pairingPage = parsePairingPage(senderUrl);
82
+ const brokerEndpoint = parseBrokerEndpoint(endpoint);
83
+ if (!pairingPage) return { ok: false, error: "invalid_pairing_page" };
84
+ if (!brokerEndpoint || pairingPage.port !== brokerEndpoint.port || !/^[A-Za-z0-9_-]{32,100}$/.test(token)) return { ok: false, error: "invalid_pairing_material" };
59
85
  const current = await chrome.storage.local.get(["endpoint", "token"]);
60
86
  const alreadyPaired = Boolean(current.endpoint && current.token);
87
+ const samePairing = current.endpoint === endpoint && current.token === token;
88
+ if (alreadyPaired && samePairing
89
+ && socket?.bridgeReady === true
90
+ && socket.readyState === WebSocket.OPEN
91
+ && socket.machineBridgeEndpoint === endpoint
92
+ && socket.machineBridgeToken === token) {
93
+ return { ok: true, replaced: false, already_connected: true };
94
+ }
61
95
  if (alreadyPaired && !replace && (current.endpoint !== endpoint || current.token !== token)) {
62
96
  return { ok: false, requires_manual_repair: true };
63
97
  }
64
- await chrome.storage.local.set({ endpoint, token });
65
98
  setConnectionState("connecting");
66
- connect(endpoint, token);
67
- return { ok: true, replaced: alreadyPaired && (current.endpoint !== endpoint || current.token !== token) };
99
+ try {
100
+ const connectedSocket = await connect(endpoint, token, { reconnect: false });
101
+ await chrome.storage.local.set({ endpoint, token });
102
+ connectedSocket.reconnectEnabled = true;
103
+ if (connectedSocket.readyState !== WebSocket.OPEN || connectedSocket.bridgeReady !== true) void connect(endpoint, token).catch(() => {});
104
+ return { ok: true, replaced: alreadyPaired && (current.endpoint !== endpoint || current.token !== token) };
105
+ } catch (error) {
106
+ if (current.endpoint && current.token) void connect(current.endpoint, current.token).catch(() => {});
107
+ throw error;
108
+ }
68
109
  }
69
110
 
70
111
  async function confirmRepairFromTab(tab) {
71
- if (!tab?.id || !/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) return;
112
+ if (!tab?.id || !parsePairingPage(tab.url)) return;
72
113
  try {
73
114
  const [result] = await chrome.scripting.executeScript({
74
115
  target: { tabId: tab.id },
@@ -86,7 +127,17 @@ async function confirmRepairFromTab(tab) {
86
127
  },
87
128
  args: [paired.ok === true],
88
129
  });
89
- } catch {}
130
+ } catch {
131
+ try {
132
+ await chrome.scripting.executeScript({
133
+ target: { tabId: tab.id },
134
+ func: () => {
135
+ const status = document.getElementById("status");
136
+ if (status) status.textContent = "Pairing failed. Reload this page and confirm that the expected extension build is loaded.";
137
+ },
138
+ });
139
+ } catch {}
140
+ }
90
141
  }
91
142
 
92
143
  ensureReconnectAlarm();
@@ -101,13 +152,13 @@ async function connectFromStorage() {
101
152
  const value = await chrome.storage.local.get(["endpoint", "token"]);
102
153
  if (value.endpoint && value.token) {
103
154
  setConnectionState("connecting");
104
- connect(value.endpoint, value.token);
155
+ void connect(value.endpoint, value.token).catch(() => {});
105
156
  } else {
106
157
  setConnectionState("unconfigured");
107
158
  }
108
159
  }
109
160
 
110
- function connect(endpoint, token) {
161
+ function connect(endpoint, token, { reconnect = true } = {}) {
111
162
  clearTimeout(reconnectTimer);
112
163
  reconnectTimer = null;
113
164
  if (socket) {
@@ -115,48 +166,106 @@ function connect(endpoint, token) {
115
166
  }
116
167
  const ws = new WebSocket(endpoint, [`mbm.${token}`]);
117
168
  socket = ws;
118
- ws.onopen = () => {
119
- if (socket !== ws) {
120
- try { ws.close(); } catch {}
121
- return;
122
- }
123
- reconnectAttempt = 0;
124
- setConnectionState("connected");
125
- clearInterval(keepaliveTimer);
126
- keepaliveTimer = setInterval(() => { if (socket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
127
- };
128
- ws.onmessage = (event) => void handleMessage(ws, event.data);
129
- ws.onerror = () => {};
130
- ws.onclose = () => {
131
- if (socket !== ws) return;
132
- clearInterval(keepaliveTimer);
133
- keepaliveTimer = null;
134
- socket = null;
135
- setConnectionState("disconnected");
136
- scheduleReconnect(endpoint, token);
137
- };
169
+ ws.bridgeReady = false;
170
+ ws.serverHelloSeen = false;
171
+ ws.machineBridgeEndpoint = endpoint;
172
+ ws.machineBridgeToken = token;
173
+ ws.reconnectEnabled = reconnect;
174
+ setConnectionState("connecting");
175
+ return new Promise((resolvePromise, rejectPromise) => {
176
+ let settled = false;
177
+ const settle = (error = null) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ if (error) rejectPromise(error); else resolvePromise(ws);
181
+ };
182
+ ws.onopen = () => {
183
+ if (socket !== ws) {
184
+ try { ws.close(); } catch {}
185
+ settle(new Error("browser connection was superseded"));
186
+ return;
187
+ }
188
+ ws.handshakeTimer = setTimeout(() => {
189
+ try { ws.close(1002, "browser broker handshake timed out"); } catch {}
190
+ }, HANDSHAKE_TIMEOUT_MS);
191
+ };
192
+ ws.onmessage = (event) => void handleMessage(ws, event.data, () => settle());
193
+ ws.onerror = () => {};
194
+ ws.onclose = () => {
195
+ clearTimeout(ws.handshakeTimer);
196
+ if (!ws.bridgeReady) settle(new Error("browser broker handshake failed"));
197
+ if (socket !== ws) return;
198
+ clearInterval(keepaliveTimer);
199
+ keepaliveTimer = null;
200
+ cancelRequestsForSocket(ws);
201
+ socket = null;
202
+ setConnectionState("disconnected");
203
+ if (ws.reconnectEnabled) scheduleReconnect(endpoint, token);
204
+ };
205
+ });
138
206
  }
139
207
 
140
208
  function scheduleReconnect(endpoint, token) {
141
209
  clearTimeout(reconnectTimer);
142
210
  const delay = Math.min(30000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
143
- reconnectTimer = setTimeout(() => connect(endpoint, token), delay);
211
+ reconnectTimer = setTimeout(() => { void connect(endpoint, token).catch(() => {}); }, delay);
144
212
  }
145
213
 
146
- async function handleMessage(ws, raw) {
214
+ async function handleMessage(ws, raw, onReady = () => {}) {
147
215
  let message;
148
- try { message = JSON.parse(String(raw)); } catch { return; }
216
+ try { message = JSON.parse(String(raw)); } catch {
217
+ try { ws.close(1007, "invalid broker JSON"); } catch {}
218
+ return;
219
+ }
220
+ if (message?.type === "hello") {
221
+ if (ws.serverHelloSeen || message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
222
+ try { ws.close(1002, "browser broker protocol mismatch"); } catch {}
223
+ return;
224
+ }
225
+ ws.serverHelloSeen = true;
226
+ const manifest = chrome.runtime.getManifest();
227
+ ws.send(JSON.stringify({
228
+ type: "hello",
229
+ role: "extension",
230
+ protocol: BROWSER_EXTENSION_PROTOCOL,
231
+ version: manifest.version_name || manifest.version,
232
+ capabilities: [
233
+ "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
234
+ ],
235
+ }));
236
+ return;
237
+ }
238
+ if (message?.type === "hello_ack") {
239
+ if (ws.bridgeReady || !ws.serverHelloSeen || message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
240
+ try { ws.close(1002, "invalid browser broker acknowledgement"); } catch {}
241
+ return;
242
+ }
243
+ clearTimeout(ws.handshakeTimer);
244
+ ws.bridgeReady = true;
245
+ reconnectAttempt = 0;
246
+ setConnectionState("connected");
247
+ clearInterval(keepaliveTimer);
248
+ keepaliveTimer = setInterval(() => {
249
+ if (socket === ws && ws.bridgeReady && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
250
+ }, 20000);
251
+ onReady();
252
+ return;
253
+ }
254
+ if (!ws.bridgeReady) {
255
+ try { ws.close(1002, "browser broker acknowledgement required"); } catch {}
256
+ return;
257
+ }
149
258
  if (message?.type === "cancel" && typeof message.id === "string") {
150
259
  const state = activeRequests.get(message.id);
151
260
  if (state) state.cancelled = true;
152
261
  return;
153
262
  }
154
263
  if (message?.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;
155
- const state = { cancelled: false };
264
+ const state = { cancelled: false, timeoutMs: browserOperations().boundedRequestTimeout(message.timeout_ms), socket: ws };
156
265
  activeRequests.set(message.id, state);
157
266
  try {
158
267
  throwIfCancelled(state);
159
- const result = await dispatch(message.method, message.params || {});
268
+ const result = await dispatch(message.method, message.params || {}, state);
160
269
  throwIfCancelled(state);
161
270
  sendResponse(ws, message.id, true, result);
162
271
  } catch (error) {
@@ -166,6 +275,12 @@ async function handleMessage(ws, raw) {
166
275
  }
167
276
  }
168
277
 
278
+ function cancelRequestsForSocket(closedSocket) {
279
+ for (const state of activeRequests.values()) {
280
+ if (state.socket === closedSocket) state.cancelled = true;
281
+ }
282
+ }
283
+
169
284
  function throwIfCancelled(state) {
170
285
  if (state.cancelled) throw new Error("browser request cancelled");
171
286
  }
@@ -179,228 +294,13 @@ function sendResponse(ws, id, ok, result, error = "") {
179
294
  try { ws.send(payload); } catch {}
180
295
  }
181
296
 
182
- async function dispatch(method, params) {
183
- if (method === "list_tabs") return listTabs(params);
184
- if (method === "get_source") return getSource(params);
185
- if (method === "inspect_page") return inspectPage(params);
186
- if (method === "action") return browserAction(params);
187
- if (method === "fill_form") return fillForm(params);
188
- if (method === "upload_files") return uploadFiles(params);
189
- if (method === "screenshot") return screenshot(params);
190
- throw new Error(`unknown browser method: ${method}`);
191
- }
192
297
 
193
- async function listTabs(params) {
194
- const query = params.currentWindow ? { currentWindow: true } : {};
195
- const tabs = await chrome.tabs.query(query);
196
- return {
197
- tabs: tabs
198
- .filter((tab) => params.includePinned !== false || !tab.pinned)
199
- .map((tab) => ({
200
- id: tab.id,
201
- window_id: tab.windowId,
202
- active: tab.active,
203
- pinned: tab.pinned,
204
- audible: tab.audible,
205
- discarded: tab.discarded,
206
- status: tab.status,
207
- title: tab.title || "",
208
- url: tab.url || "",
209
- })),
210
- };
298
+ function browserOperations() {
299
+ const api = globalThis.__machineBridgeBrowserOperations;
300
+ if (!api || typeof api.dispatch !== "function") throw new Error("browser operations module is unavailable");
301
+ return api;
211
302
  }
212
303
 
213
- function scriptTarget(tabId, frameId, allFrames) {
214
- if (Number.isInteger(frameId)) return { tabId, frameIds: [frameId] };
215
- if (allFrames) return { tabId, allFrames: true };
216
- return { tabId };
217
- }
218
-
219
- async function resolveTab(tabId) {
220
- if (Number.isInteger(tabId)) return chrome.tabs.get(tabId);
221
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
222
- if (!tab?.id) throw new Error("no active browser tab");
223
- return tab;
224
- }
225
-
226
- async function getSource(params) {
227
- const tab = await resolveTab(params.tabId);
228
- assertPageTab(tab);
229
- const maxBytes = Number(params.maxBytes) || 1024 * 1024;
230
- const executions = await chrome.scripting.executeScript({
231
- target: scriptTarget(tab.id, params.frameId, params.allFrames === true),
232
- func: (limit) => {
233
- const doctype = document.doctype ? `<!DOCTYPE ${document.doctype.name}>\n` : "";
234
- const source = `${doctype}${document.documentElement?.outerHTML || ""}`;
235
- const encoder = new TextEncoder();
236
- const bytes = encoder.encode(source);
237
- if (bytes.byteLength <= limit) return { source, bytes: bytes.byteLength, truncated: false, url: location.href };
238
- const decoder = new TextDecoder();
239
- return { source: decoder.decode(bytes.slice(0, limit)), bytes: bytes.byteLength, truncated: true, url: location.href };
240
- },
241
- args: [maxBytes],
242
- });
243
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
244
- }
245
-
246
- async function inspectPage(params) {
247
- const tab = await resolveTab(params.tabId);
248
- assertPageTab(tab);
249
- const executions = await executePageAutomation(
250
- scriptTarget(tab.id, params.frameId, params.allFrames === true),
251
- "inspect",
252
- { maxElements: Number(params.maxElements) || 300, includeValues: params.includeValues === true },
253
- );
254
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
255
- }
256
-
257
- async function browserAction(params) {
258
- const tab = await resolveTab(params.tabId);
259
- if (params.action === "navigate") {
260
- if (!params.url) throw new Error("navigate requires url");
261
- const waiter = beginTabWait(tab.id, params.waitFor);
262
- try {
263
- const updated = await chrome.tabs.update(tab.id, { url: params.url });
264
- await waiter.promise;
265
- return publicTab(await chrome.tabs.get(updated.id));
266
- } catch (error) {
267
- waiter.cancel();
268
- throw error;
269
- }
270
- }
271
- if (params.action === "reload") {
272
- const waiter = beginTabWait(tab.id, params.waitFor);
273
- try {
274
- await chrome.tabs.reload(tab.id);
275
- await waiter.promise;
276
- return publicTab(await chrome.tabs.get(tab.id));
277
- } catch (error) {
278
- waiter.cancel();
279
- throw error;
280
- }
281
- }
282
- if (params.action === "back" || params.action === "forward") {
283
- const waiter = beginTabWait(tab.id, params.waitFor);
284
- try {
285
- if (params.action === "back") await chrome.tabs.goBack(tab.id);
286
- else await chrome.tabs.goForward(tab.id);
287
- await waiter.promise;
288
- return publicTab(await chrome.tabs.get(tab.id));
289
- } catch (error) {
290
- waiter.cancel();
291
- throw error;
292
- }
293
- }
294
- assertPageTab(tab);
295
- const waiter = beginTabWait(tab.id, params.waitFor);
296
- let execution;
297
- try {
298
- [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
299
- await waiter.promise;
300
- } catch (error) {
301
- waiter.cancel();
302
- throw error;
303
- }
304
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
305
- }
306
-
307
- async function fillForm(params) {
308
- const tab = await resolveTab(params.tabId);
309
- assertPageTab(tab);
310
- const waiter = beginTabWait(tab.id, params.waitFor);
311
- let execution;
312
- try {
313
- [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "fillForm", params);
314
- await waiter.promise;
315
- } catch (error) {
316
- waiter.cancel();
317
- throw error;
318
- }
319
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
320
- }
321
-
322
- async function uploadFiles(params) {
323
- const tab = await resolveTab(params.tabId);
324
- assertPageTab(tab);
325
- const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "uploadFiles", params);
326
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
327
- }
328
-
329
- async function screenshot(params) {
330
- const tab = await resolveTab(params.tabId);
331
- await chrome.tabs.update(tab.id, { active: true });
332
- await chrome.windows.update(tab.windowId, { focused: true });
333
- const data = await chrome.tabs.captureVisibleTab(tab.windowId, {
334
- format: params.format === "jpeg" ? "jpeg" : "png",
335
- quality: Number(params.quality) || 90,
336
- });
337
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", data };
338
- }
339
-
340
- function publicTab(tab) {
341
- return { tab_id: tab.id, window_id: tab.windowId, title: tab.title || "", url: tab.url || "", status: tab.status || "" };
342
- }
343
-
344
- function assertPageTab(tab) {
345
- const url = String(tab.url || "");
346
- if (!/^(https?|file):/i.test(url)) throw new Error("this page cannot be scripted by a browser extension");
347
- }
348
-
349
- function beginTabWait(tabId, mode) {
350
- if (!mode || mode === "none") return { promise: Promise.resolve(), cancel() {} };
351
- let settled = false;
352
- let navigationStarted = false;
353
- let pollTimer = null;
354
- let timeout = null;
355
- let resolveWait;
356
- let rejectWait;
357
- const promise = new Promise((resolvePromise, rejectPromise) => {
358
- resolveWait = resolvePromise;
359
- rejectWait = rejectPromise;
360
- });
361
- const cleanup = () => {
362
- clearTimeout(timeout);
363
- clearTimeout(pollTimer);
364
- chrome.tabs.onUpdated.removeListener(listener);
365
- };
366
- const settle = (error = null) => {
367
- if (settled) return;
368
- settled = true;
369
- cleanup();
370
- if (error) rejectWait(error); else resolveWait();
371
- };
372
- const pollReadyState = async () => {
373
- if (settled || !navigationStarted || mode !== "domcontentloaded") return;
374
- try {
375
- const [execution] = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.readyState });
376
- if (["interactive", "complete"].includes(execution?.result)) {
377
- settle();
378
- return;
379
- }
380
- } catch {}
381
- pollTimer = setTimeout(pollReadyState, 100);
382
- };
383
- const listener = (updatedId, changeInfo, tab) => {
384
- if (updatedId !== tabId || settled) return;
385
- if (changeInfo.status === "loading" || typeof changeInfo.url === "string") navigationStarted = true;
386
- if (!navigationStarted) return;
387
- if (mode === "complete" && (changeInfo.status === "complete" || (typeof changeInfo.url === "string" && tab.status === "complete"))) settle();
388
- if (mode === "domcontentloaded") void pollReadyState();
389
- };
390
- chrome.tabs.onUpdated.addListener(listener);
391
- timeout = setTimeout(() => settle(new Error("browser navigation wait timed out")), 30000);
392
- return { promise, cancel: () => settle() };
393
- }
394
-
395
- function executePageAutomation(target, method, params) {
396
- return chrome.scripting.executeScript({ target, files: ["page-automation.js"] })
397
- .then(() => chrome.scripting.executeScript({
398
- target,
399
- func: (operation, payload) => {
400
- const api = globalThis.__machineBridgePageAutomation;
401
- if (!api || typeof api[operation] !== "function") throw new Error("page automation module is unavailable");
402
- return api[operation](payload);
403
- },
404
- args: [method, params],
405
- }));
304
+ function dispatch(method, params, state) {
305
+ return browserOperations().dispatch(method, params, state);
406
306
  }
@@ -60,7 +60,7 @@ See [Session instructions, skills, commands, and capability discovery](AGENT_CON
60
60
 
61
61
  `BrowserBridgeManager` owns a loopback HTTP/WebSocket broker. The first runtime for the machine-level state root becomes broker owner; additional workspaces and stdio runtimes authenticate to `/runtime` and proxy through the same extension socket. This preserves one extension pairing while allowing multiple local MCP runtimes.
62
62
 
63
- The packaged Manifest V3 extension runs in the user's existing Chromium profile. Its service worker is transport/orchestration only; a fixed packaged `page-automation.js` module is injected into selected frames to expose DOM operations in the correct execution world. It supports accessible frames, open Shadow DOM roots, structured actions, multi-field forms, resource-backed file inputs, and screenshots. The extension never receives arbitrary caller-provided code. The broker validates loopback hostnames, extension origin, bearer subprotocols, message sizes, concurrency, and timeouts. Pairing material is owner-only and omitted from MCP/log output.
63
+ The packaged Manifest V3 extension runs in the user's existing Chromium profile. Its service worker is limited to pairing, transport, acknowledged protocol readiness, cancellation, and response routing. Fixed `browser-operations.js` owns tab lifecycle, aggregate frame/source budgets, waits, screenshots, and input-backend selection; fixed `page-automation.js` is injected into selected frames for snapshot-version-2 semantics, stable refs, bounded DOM/text traversal, actionability checks, open-Shadow-DOM traversal, structured DOM operations, multi-field forms, and resource-backed file inputs. Fixed `devtools-input.js` exposes only bounded mouse, keyboard, and text sequences through the Chromium debugger API; callers cannot select CDP methods. Trusted sessions attach for one action and detach in `finally`; DOM fallback is allowed only before any DevTools Input dispatch, preventing duplicate side effects after an ambiguous command failure. Protocol 3 requires bidirectional `hello`/`hello_ack` plus exact packaged-version and capability equality; pairing state and replacement are committed only after validation, so an invalid candidate cannot displace or overwrite the working configuration. The broker validates loopback hostnames, canonical extension IDs, matching pairing/broker ports, bearer subprotocols, message sizes, concurrency, and deadlines. Pairing material is owner-only and omitted from MCP/log output.
64
64
 
65
65
  See [Local application and browser automation](LOCAL_AUTOMATION.md).
66
66
 
package/docs/AUDIT.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Engineering and security audit
2
2
 
3
- This document records the cross-cutting audit initiated for version 0.12.0, the 0.12.2 cross-platform CI follow-up, and the 0.13.0 architecture/automatic-routing follow-up. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
3
+ This document records the cross-cutting audit initiated for version 0.12.0, the 0.12.2 cross-platform CI follow-up, and the 0.13.0 architecture/automatic-routing follow-up, plus the 0.15.0 daily-browser and cross-platform hardening review. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
4
+
5
+ ## 2026-07-13 daily-browser and cross-platform hardening follow-up
6
+
7
+ The review first distinguished installation from actual connectivity. The user identified the target as their ordinary Chrome profile and manually loaded the unpacked extension there; it was initially unpaired. After opening the genuine loopback pairing page, a localhost-only live fixture verified that this connected Chrome instance could create and close a real tab, return semantic snapshot refs, traverse an open Shadow DOM, fill a multi-field form, issue trusted text and pointer input, wait for final DOM state, and capture a screenshot without reading unrelated tabs. The product now states the narrower machine-verifiable fact: Machine Bridge does not launch a browser, but an extension cannot infer whether its host profile is the user's daily or isolated profile.
8
+
9
+ Fault injection then found several state-machine issues that a success-only smoke test could not reveal. WebSocket `open` was treated as extension readiness before protocol validation; pairing state was written before the candidate authenticated; `input_mode: auto` replayed a DOM action after any DevTools failure even when an Input command might already have taken effect; screenshot capture focused the browser window and did not restore the previous active tab; navigation wait used a hidden 30-second deadline independent of the request; and replacement could leave direct/proxied requests pending. Protocol 3 now requires `hello`/`hello_ack` with exact packaged-version/capability equality, persists pairing only after acknowledgement, validates a canonical extension ID plus matching loopback ports, propagates the request deadline, rejects ambiguous post-dispatch retries, restores tab state without stealing focus, and fails interrupted routes with retry guidance.
10
+
11
+ Hostile-page analysis found that previous limits were result limits rather than work limits: every frame received the full source/element allowance, `outerHTML` was built before truncation, interactive elements and Shadow roots were fully enumerated before slicing, page-controlled strings were not uniformly bounded, and whole-page `innerText` was materialized for waits. Version 0.15.0 applies aggregate budgets across at most 64 frames, uses iterative bounded DOM serialization, limits each page scan to 100,000 nodes, reusable refs to a 10,000-entry LRU, and text search to 2 MiB, bounds metadata, removes URL userinfo, treats secret-like contenteditable controls as sensitive, and reports truncation explicitly. Multi-field failures now identify possible earlier mutations without returning values.
12
+
13
+ The extension architecture was also corrected: the service worker now owns only pairing/transport/readiness/cancellation/response routing, while fixed `browser-operations.js` owns tab and page orchestration. An executable line-count invariant prevents those concerns from silently merging again. Outside the browser path, the review found that Windows Scheduled Task command construction did not implement CRT backslash-before-quote rules; drive-root or trailing-backslash arguments could be misparsed. A dedicated quoting primitive and platform regression test correct that boundary.
14
+
15
+ The same pass rechecked Worker OAuth/PKCE, registration/code/token limits, process sessions, managed jobs, state deletion, log redaction/rotation, package contents, dependencies, open issues, and current CI. No additional high-impact code change was justified in those areas. Their documented residuals remain: canonical `full` intentionally exposes the local user's authority, service logs are trimmed at startup rather than continuously, and same-user malicious code or an MCP host can still operate outside Machine Bridge's control.
4
16
 
5
17
  ## 2026-07-13 architecture and automatic-routing follow-up
6
18
 
@@ -13,15 +13,17 @@ Launching a separate automation browser is predictable but loses the user's ordi
13
13
 
14
14
  The extension works with the current profile and supports:
15
15
 
16
- - listing existing tabs;
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, labels, roles, placeholders, and field metadata across accessible frames and open Shadow DOM roots;
19
- - structured navigation, click, focus, fill, select, check, uncheck, key press, and form submission;
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
+ - waiting for bounded combinations of URL, load state, page text, and element state;
20
+ - structured navigation, click, double-click, hover, focus, fill, type-text, select, check, uncheck, key press, scroll-into-view, and form submission;
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;
20
22
  - multi-field complex form filling in one operation, bounded to 200 fields and 4 MiB of aggregate text;
21
23
  - populating file inputs from registered local resource files;
22
24
  - visible-tab screenshots.
23
25
 
24
- Current support targets Chrome, Chromium, Microsoft Edge, Brave, Vivaldi, and compatible Chromium browsers. After upgrading Machine Bridge, reload the unpacked extension from the browser extensions page so its packaged scripts match the new runtime. Browser-internal pages, extension stores, some PDF/plugin viewers, cross-origin frames denied to the extension, and pages restricted by enterprise policy may not be scriptable.
26
+ Current support targets Chrome, Chromium, Microsoft Edge, Brave, Vivaldi, and compatible Chromium browsers. The extension must be loaded into the user profile to be controlled; it is not installed into Playwright or an isolated automation profile. After every Machine Bridge upgrade, reload the unpacked extension so packaged scripts, protocol, and permissions match the runtime. Version 0.15.0 requires protocol 3 with broker `hello_ack`; the badge and pairing page report success only after protocol/version/capability validation. Pairing material is persisted only after that acknowledgement, an invalid candidate cannot overwrite the previous pairing, and `browser status` reports the expected packaged build, authenticated connected build/protocol/capabilities, and `extension_reload_required`; exact version equality is required in addition to protocol and capabilities. Browser-internal pages, extension stores, some PDF/plugin viewers, inaccessible cross-origin frames, and pages restricted by enterprise policy remain unscriptable.
25
27
 
26
28
  ## One-time browser setup
27
29
 
@@ -53,17 +55,19 @@ The broker is machine-global rather than workspace-global. One local owner liste
53
55
 
54
56
  ## Browser tools
55
57
 
56
- - `browser_status` reports broker role, extension connection, supported operations, pairing URL, and extension path without returning the pairing token.
58
+ - `browser_status` reports broker role, authenticated extension protocol/version/capabilities, reload state, supported operations, pairing URL, and extension path without returning the pairing token.
57
59
  - `pair_browser_extension` opens the local pairing page and returns setup steps.
58
60
  - `browser_list_tabs` lists current tabs.
59
- - `browser_get_source` returns bounded current DOM HTML for selected frames.
60
- - `browser_inspect_page` returns bounded interactive-element metadata.
61
- - `browser_action` performs one structured navigation or DOM action. Navigation accepts absolute `http`, `https`, or `file` URLs; script/data schemes are rejected.
62
- - `browser_fill_form` fills up to 200 fields and can submit once.
61
+ - `browser_manage_tabs` creates, activates, or closes a selected tab.
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
+ - `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 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.
63
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.
64
- - `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.
65
69
 
66
- Selectors can use CSS, ID, field name, label text, visible text, ARIA/implicit role, placeholder, and a zero-based match index. 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.
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.
67
71
 
68
72
  ## Sensitive form values and files
69
73
 
@@ -100,13 +104,13 @@ The browser extension has broad page access because generic source inspection an
100
104
  The implementation reduces avoidable risk as follows:
101
105
 
102
106
  - all browser/app tools are `full`-only;
103
- - no arbitrary evaluation or caller-provided script source is accepted;
104
- - loopback HTTP validates `Host`; extension WebSockets require a random bearer subprotocol and a `chrome-extension://` origin;
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 canonical `chrome-extension://` origin whose host is a 32-character Chromium extension ID; pairing-page and broker ports must match;
105
109
  - runtime-to-broker connections require a separate authenticated subprotocol;
106
110
  - the pairing token is stored owner-only, embedded only in the non-cacheable local pairing page, and omitted from MCP results and logs;
107
111
  - an established extension pairing cannot be silently replaced by another localhost page; replacement requires clicking the extension action on the active pairing page;
108
112
  - arguments, form values, page source, screenshots, and results are not operational log data;
109
- - message, source, form-field, upload, traversal, concurrency, proxy-route, and timeout limits are enforced;
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;
110
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;
111
115
  - resource-backed text and files are not returned after injection.
112
116