machine-bridge-mcp 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
1
- importScripts("devtools-input.js");
1
+ importScripts("devtools-input.js", "browser-operations.js");
2
2
 
3
3
  let socket = null;
4
4
  let reconnectTimer = null;
5
5
  let reconnectAttempt = 0;
6
6
  let keepaliveTimer = null;
7
7
  const MAX_RESULT_BYTES = 7 * 1024 * 1024;
8
+ const BROWSER_EXTENSION_PROTOCOL = 3;
9
+ const HANDSHAKE_TIMEOUT_MS = 3000;
8
10
  const activeRequests = new Map();
9
11
 
10
12
  chrome.runtime.onInstalled.addListener(() => { ensureReconnectAlarm(); void connectFromStorage(); });
@@ -22,7 +24,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
22
24
  chrome.action.onClicked.addListener((tab) => void handleActionClick(tab));
23
25
 
24
26
  async function handleActionClick(tab) {
25
- if (tab?.id && /^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) {
27
+ if (tab?.id && parsePairingPage(tab.url)) {
26
28
  await confirmRepairFromTab(tab);
27
29
  return;
28
30
  }
@@ -32,8 +34,28 @@ async function handleActionClick(tab) {
32
34
  }
33
35
 
34
36
  function pairingUrlFromEndpoint(endpoint) {
35
- const match = /^ws:\/\/127\.0\.0\.1:(\d+)\/extension$/.exec(String(endpoint || ""));
36
- 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;
37
59
  }
38
60
 
39
61
  function setConnectionState(state) {
@@ -56,21 +78,38 @@ function ignoreOptionalPromise(value) {
56
78
  async function pairConfiguration(rawEndpoint, rawToken, { replace, senderUrl }) {
57
79
  const endpoint = String(rawEndpoint || "");
58
80
  const token = String(rawToken || "");
59
- if (!/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(senderUrl || ""))) return { ok: false, error: "invalid_pairing_page" };
60
- 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" };
61
85
  const current = await chrome.storage.local.get(["endpoint", "token"]);
62
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
+ }
63
95
  if (alreadyPaired && !replace && (current.endpoint !== endpoint || current.token !== token)) {
64
96
  return { ok: false, requires_manual_repair: true };
65
97
  }
66
- await chrome.storage.local.set({ endpoint, token });
67
98
  setConnectionState("connecting");
68
- connect(endpoint, token);
69
- 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
+ }
70
109
  }
71
110
 
72
111
  async function confirmRepairFromTab(tab) {
73
- if (!tab?.id || !/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) return;
112
+ if (!tab?.id || !parsePairingPage(tab.url)) return;
74
113
  try {
75
114
  const [result] = await chrome.scripting.executeScript({
76
115
  target: { tabId: tab.id },
@@ -88,7 +127,17 @@ async function confirmRepairFromTab(tab) {
88
127
  },
89
128
  args: [paired.ok === true],
90
129
  });
91
- } 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
+ }
92
141
  }
93
142
 
94
143
  ensureReconnectAlarm();
@@ -103,13 +152,13 @@ async function connectFromStorage() {
103
152
  const value = await chrome.storage.local.get(["endpoint", "token"]);
104
153
  if (value.endpoint && value.token) {
105
154
  setConnectionState("connecting");
106
- connect(value.endpoint, value.token);
155
+ void connect(value.endpoint, value.token).catch(() => {});
107
156
  } else {
108
157
  setConnectionState("unconfigured");
109
158
  }
110
159
  }
111
160
 
112
- function connect(endpoint, token) {
161
+ function connect(endpoint, token, { reconnect = true } = {}) {
113
162
  clearTimeout(reconnectTimer);
114
163
  reconnectTimer = null;
115
164
  if (socket) {
@@ -117,54 +166,102 @@ function connect(endpoint, token) {
117
166
  }
118
167
  const ws = new WebSocket(endpoint, [`mbm.${token}`]);
119
168
  socket = ws;
120
- ws.onopen = () => {
121
- if (socket !== ws) {
122
- try { ws.close(); } catch {}
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
+ });
206
+ }
207
+
208
+ function scheduleReconnect(endpoint, token) {
209
+ clearTimeout(reconnectTimer);
210
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
211
+ reconnectTimer = setTimeout(() => { void connect(endpoint, token).catch(() => {}); }, delay);
212
+ }
213
+
214
+ async function handleMessage(ws, raw, onReady = () => {}) {
215
+ let message;
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 {}
123
223
  return;
124
224
  }
125
- reconnectAttempt = 0;
225
+ ws.serverHelloSeen = true;
126
226
  const manifest = chrome.runtime.getManifest();
127
227
  ws.send(JSON.stringify({
128
228
  type: "hello",
129
229
  role: "extension",
130
- protocol: 2,
230
+ protocol: BROWSER_EXTENSION_PROTOCOL,
131
231
  version: manifest.version_name || manifest.version,
132
232
  capabilities: [
133
233
  "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
134
234
  ],
135
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;
136
246
  setConnectionState("connected");
137
247
  clearInterval(keepaliveTimer);
138
- keepaliveTimer = setInterval(() => { if (socket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
139
- };
140
- ws.onmessage = (event) => void handleMessage(ws, event.data);
141
- ws.onerror = () => {};
142
- ws.onclose = () => {
143
- if (socket !== ws) return;
144
- clearInterval(keepaliveTimer);
145
- keepaliveTimer = null;
146
- socket = null;
147
- setConnectionState("disconnected");
148
- scheduleReconnect(endpoint, token);
149
- };
150
- }
151
-
152
- function scheduleReconnect(endpoint, token) {
153
- clearTimeout(reconnectTimer);
154
- const delay = Math.min(30000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
155
- reconnectTimer = setTimeout(() => connect(endpoint, token), delay);
156
- }
157
-
158
- async function handleMessage(ws, raw) {
159
- let message;
160
- try { message = JSON.parse(String(raw)); } catch { return; }
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
+ }
161
258
  if (message?.type === "cancel" && typeof message.id === "string") {
162
259
  const state = activeRequests.get(message.id);
163
260
  if (state) state.cancelled = true;
164
261
  return;
165
262
  }
166
263
  if (message?.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;
167
- const state = { cancelled: false };
264
+ const state = { cancelled: false, timeoutMs: browserOperations().boundedRequestTimeout(message.timeout_ms), socket: ws };
168
265
  activeRequests.set(message.id, state);
169
266
  try {
170
267
  throwIfCancelled(state);
@@ -178,6 +275,12 @@ async function handleMessage(ws, raw) {
178
275
  }
179
276
  }
180
277
 
278
+ function cancelRequestsForSocket(closedSocket) {
279
+ for (const state of activeRequests.values()) {
280
+ if (state.socket === closedSocket) state.cancelled = true;
281
+ }
282
+ }
283
+
181
284
  function throwIfCancelled(state) {
182
285
  if (state.cancelled) throw new Error("browser request cancelled");
183
286
  }
@@ -191,319 +294,13 @@ function sendResponse(ws, id, ok, result, error = "") {
191
294
  try { ws.send(payload); } catch {}
192
295
  }
193
296
 
194
- async function dispatch(method, params, state) {
195
- if (method === "list_tabs") return listTabs(params);
196
- if (method === "manage_tabs") return manageTabs(params);
197
- if (method === "wait") return browserWait(params, state);
198
- if (method === "get_source") return getSource(params);
199
- if (method === "inspect_page") return inspectPage(params);
200
- if (method === "action") return browserAction(params);
201
- if (method === "fill_form") return fillForm(params);
202
- if (method === "upload_files") return uploadFiles(params);
203
- if (method === "screenshot") return screenshot(params);
204
- throw new Error(`unknown browser method: ${method}`);
205
- }
206
-
207
- async function listTabs(params) {
208
- const query = params.currentWindow ? { currentWindow: true } : {};
209
- const tabs = await chrome.tabs.query(query);
210
- return {
211
- tabs: tabs
212
- .filter((tab) => params.includePinned !== false || !tab.pinned)
213
- .map((tab) => ({
214
- id: tab.id,
215
- window_id: tab.windowId,
216
- active: tab.active,
217
- pinned: tab.pinned,
218
- audible: tab.audible,
219
- discarded: tab.discarded,
220
- status: tab.status,
221
- title: tab.title || "",
222
- url: tab.url || "",
223
- })),
224
- };
225
- }
226
-
227
297
 
228
- async function manageTabs(params) {
229
- if (params.action === "new") {
230
- const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: params.active !== false });
231
- return { action: "new", ...publicTab(tab) };
232
- }
233
- const tab = await chrome.tabs.get(params.tabId);
234
- if (params.action === "activate") {
235
- await chrome.tabs.update(tab.id, { active: true });
236
- await chrome.windows.update(tab.windowId, { focused: true });
237
- return { action: "activate", ...publicTab(await chrome.tabs.get(tab.id)) };
238
- }
239
- if (params.action === "close") {
240
- const result = { action: "close", closed: true, ...publicTab(tab) };
241
- await chrome.tabs.remove(tab.id);
242
- return result;
243
- }
244
- throw new Error("unsupported browser tab action");
245
- }
246
-
247
- async function browserWait(params, state) {
248
- const tab = await resolveTab(params.tabId);
249
- const deadline = Date.now() + Math.max(1, Number(params.timeoutMs) || 30000);
250
- let last = null;
251
- while (Date.now() <= deadline) {
252
- throwIfCancelled(state);
253
- const current = await chrome.tabs.get(tab.id);
254
- const urlMatched = !params.urlContains || String(current.url || "").includes(params.urlContains);
255
- const needsPage = Boolean(params.selector || params.text || params.loadState);
256
- let page = { matched: true, ready_state: current.status || "" };
257
- if (needsPage) {
258
- try {
259
- assertPageTab(current);
260
- const [execution] = await executePageAutomation(scriptTarget(current.id, params.frameId, false), "checkWait", params);
261
- page = execution?.result || { matched: false };
262
- } catch (error) {
263
- const message = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
264
- if (message.includes("invalid CSS selector") || message.includes("selector matched")) throw new Error(message);
265
- page = { matched: false, error: message };
266
- }
267
- }
268
- last = { url_matched: urlMatched, ...page };
269
- if (urlMatched && page.matched === true) {
270
- return { ok: true, tab_id: current.id, title: current.title || "", url: current.url || "", condition: last };
271
- }
272
- await delay(200);
273
- }
274
- throw new Error(`browser wait timed out; last condition: ${JSON.stringify(last || {})}`);
275
- }
276
- function scriptTarget(tabId, frameId, allFrames) {
277
- if (Number.isInteger(frameId)) return { tabId, frameIds: [frameId] };
278
- if (allFrames) return { tabId, allFrames: true };
279
- return { tabId };
280
- }
281
-
282
- async function resolveTab(tabId) {
283
- if (Number.isInteger(tabId)) return chrome.tabs.get(tabId);
284
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
285
- if (!tab?.id) throw new Error("no active browser tab");
286
- return tab;
287
- }
288
-
289
- async function getSource(params) {
290
- const tab = await resolveTab(params.tabId);
291
- assertPageTab(tab);
292
- const maxBytes = Number(params.maxBytes) || 1024 * 1024;
293
- const executions = await chrome.scripting.executeScript({
294
- target: scriptTarget(tab.id, params.frameId, params.allFrames === true),
295
- func: (limit) => {
296
- const doctype = document.doctype ? `<!DOCTYPE ${document.doctype.name}>\n` : "";
297
- const source = `${doctype}${document.documentElement?.outerHTML || ""}`;
298
- const encoder = new TextEncoder();
299
- const bytes = encoder.encode(source);
300
- if (bytes.byteLength <= limit) return { source, bytes: bytes.byteLength, truncated: false, url: location.href };
301
- const decoder = new TextDecoder();
302
- return { source: decoder.decode(bytes.slice(0, limit)), bytes: bytes.byteLength, truncated: true, url: location.href };
303
- },
304
- args: [maxBytes],
305
- });
306
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
307
- }
308
-
309
- async function inspectPage(params) {
310
- const tab = await resolveTab(params.tabId);
311
- assertPageTab(tab);
312
- const executions = await executePageAutomation(
313
- scriptTarget(tab.id, params.frameId, params.allFrames === true),
314
- "inspect",
315
- { maxElements: Number(params.maxElements) || 300, includeValues: params.includeValues === true },
316
- );
317
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
318
- }
319
-
320
- async function browserAction(params) {
321
- const tab = await resolveTab(params.tabId);
322
- if (params.action === "navigate") {
323
- if (!params.url) throw new Error("navigate requires url");
324
- const waiter = beginTabWait(tab.id, params.waitFor);
325
- try {
326
- const updated = await chrome.tabs.update(tab.id, { url: params.url });
327
- await waiter.promise;
328
- return publicTab(await chrome.tabs.get(updated.id));
329
- } catch (error) {
330
- waiter.cancel();
331
- throw error;
332
- }
333
- }
334
- if (params.action === "reload") {
335
- const waiter = beginTabWait(tab.id, params.waitFor);
336
- try {
337
- await chrome.tabs.reload(tab.id);
338
- await waiter.promise;
339
- return publicTab(await chrome.tabs.get(tab.id));
340
- } catch (error) {
341
- waiter.cancel();
342
- throw error;
343
- }
344
- }
345
- if (params.action === "back" || params.action === "forward") {
346
- const waiter = beginTabWait(tab.id, params.waitFor);
347
- try {
348
- if (params.action === "back") await chrome.tabs.goBack(tab.id);
349
- else await chrome.tabs.goForward(tab.id);
350
- await waiter.promise;
351
- return publicTab(await chrome.tabs.get(tab.id));
352
- } catch (error) {
353
- waiter.cancel();
354
- throw error;
355
- }
356
- }
357
- assertPageTab(tab);
358
- const waiter = beginTabWait(tab.id, params.waitFor);
359
- let result;
360
- try {
361
- result = await performPageAction(tab, params);
362
- await waiter.promise;
363
- } catch (error) {
364
- waiter.cancel();
365
- throw error;
366
- }
367
- const current = await chrome.tabs.get(tab.id).catch(() => tab);
368
- return { tab_id: current.id, title: current.title || "", url: current.url || "", ...result };
369
- }
370
-
371
- async function performPageAction(tab, params) {
372
- const trustedActions = new Set(["click", "double_click", "hover", "press", "type_text"]);
373
- if (params.inputMode === "trusted" && !trustedActions.has(params.action)) {
374
- throw new Error("trusted input is unavailable for this action");
375
- }
376
- const wantsTrusted = params.inputMode !== "dom" && trustedActions.has(params.action);
377
- const topFrame = !Number.isInteger(params.frameId) || params.frameId === 0;
378
- if (wantsTrusted && !topFrame && params.inputMode === "trusted") {
379
- throw new Error("trusted input currently requires the top frame; use input_mode=dom for a subframe");
380
- }
381
- if (wantsTrusted && topFrame) {
382
- const [prepared] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "prepareAction", params);
383
- try {
384
- const api = globalThis.__machineBridgeDevtoolsInput;
385
- if (!api?.perform) throw new Error("trusted input module is unavailable");
386
- await api.perform(tab.id, params.action, {
387
- point: prepared.result?.point,
388
- key: params.key || params.value || "Enter",
389
- text: params.value || "",
390
- });
391
- return { ...prepared.result, input_mode: "trusted", trusted_input_fallback: false };
392
- } catch (error) {
393
- if (params.inputMode === "trusted") throw error;
394
- const [fallback] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
395
- return {
396
- ...fallback.result,
397
- input_mode: "dom",
398
- trusted_input_fallback: true,
399
- fallback_reason: String(error?.message || error).slice(0, 500),
400
- };
401
- }
402
- }
403
- const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
404
- return { ...execution.result, input_mode: "dom", trusted_input_fallback: false };
405
- }
406
- async function fillForm(params) {
407
- const tab = await resolveTab(params.tabId);
408
- assertPageTab(tab);
409
- const waiter = beginTabWait(tab.id, params.waitFor);
410
- let execution;
411
- try {
412
- [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "fillForm", params);
413
- await waiter.promise;
414
- } catch (error) {
415
- waiter.cancel();
416
- throw error;
417
- }
418
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
419
- }
420
-
421
- async function uploadFiles(params) {
422
- const tab = await resolveTab(params.tabId);
423
- assertPageTab(tab);
424
- const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "uploadFiles", params);
425
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
426
- }
427
-
428
- async function screenshot(params) {
429
- const tab = await resolveTab(params.tabId);
430
- await chrome.tabs.update(tab.id, { active: true });
431
- await chrome.windows.update(tab.windowId, { focused: true });
432
- const data = await chrome.tabs.captureVisibleTab(tab.windowId, {
433
- format: params.format === "jpeg" ? "jpeg" : "png",
434
- quality: Number(params.quality) || 90,
435
- });
436
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", data };
437
- }
438
-
439
- function publicTab(tab) {
440
- return { tab_id: tab.id, window_id: tab.windowId, title: tab.title || "", url: tab.url || "", status: tab.status || "" };
441
- }
442
-
443
- function assertPageTab(tab) {
444
- const url = String(tab.url || "");
445
- if (!/^(https?|file):/i.test(url)) throw new Error("this page cannot be scripted by a browser extension");
446
- }
447
-
448
- function beginTabWait(tabId, mode) {
449
- if (!mode || mode === "none") return { promise: Promise.resolve(), cancel() {} };
450
- let settled = false;
451
- let navigationStarted = false;
452
- let pollTimer = null;
453
- let timeout = null;
454
- let resolveWait;
455
- let rejectWait;
456
- const promise = new Promise((resolvePromise, rejectPromise) => {
457
- resolveWait = resolvePromise;
458
- rejectWait = rejectPromise;
459
- });
460
- const cleanup = () => {
461
- clearTimeout(timeout);
462
- clearTimeout(pollTimer);
463
- chrome.tabs.onUpdated.removeListener(listener);
464
- };
465
- const settle = (error = null) => {
466
- if (settled) return;
467
- settled = true;
468
- cleanup();
469
- if (error) rejectWait(error); else resolveWait();
470
- };
471
- const pollReadyState = async () => {
472
- if (settled || !navigationStarted || mode !== "domcontentloaded") return;
473
- try {
474
- const [execution] = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.readyState });
475
- if (["interactive", "complete"].includes(execution?.result)) {
476
- settle();
477
- return;
478
- }
479
- } catch {}
480
- pollTimer = setTimeout(pollReadyState, 100);
481
- };
482
- const listener = (updatedId, changeInfo, tab) => {
483
- if (updatedId !== tabId || settled) return;
484
- if (changeInfo.status === "loading" || typeof changeInfo.url === "string") navigationStarted = true;
485
- if (!navigationStarted) return;
486
- if (mode === "complete" && (changeInfo.status === "complete" || (typeof changeInfo.url === "string" && tab.status === "complete"))) settle();
487
- if (mode === "domcontentloaded") void pollReadyState();
488
- };
489
- chrome.tabs.onUpdated.addListener(listener);
490
- timeout = setTimeout(() => settle(new Error("browser navigation wait timed out")), 30000);
491
- return { promise, cancel: () => settle() };
492
- }
493
-
494
- function executePageAutomation(target, method, params) {
495
- return chrome.scripting.executeScript({ target, files: ["page-automation.js"] })
496
- .then(() => chrome.scripting.executeScript({
497
- target,
498
- func: (operation, payload) => {
499
- const api = globalThis.__machineBridgePageAutomation;
500
- if (!api || typeof api[operation] !== "function") throw new Error("page automation module is unavailable");
501
- return api[operation](payload);
502
- },
503
- args: [method, params],
504
- }));
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;
505
302
  }
506
303
 
507
- function delay(ms) {
508
- return new Promise((resolve) => setTimeout(resolve, ms));
304
+ function dispatch(method, params, state) {
305
+ return browserOperations().dispatch(method, params, state);
509
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 owns transport, tab lifecycle, bounded waits, and selection between input backends. A fixed packaged `page-automation.js` module is injected into selected frames for semantic snapshots, stable element refs, actionability checks, open-Shadow-DOM traversal, structured DOM operations, multi-field forms, and resource-backed file inputs. A separate fixed `devtools-input.js` module 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`, with explicit DOM fallback policy. A versioned capability handshake prevents a stale unpacked extension from being treated as compatible; replacement is committed only after validation, so an invalid candidate cannot displace the current working connection. Extension keepalive `ping` messages are an explicit protocol operation. 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