machine-bridge-mcp 0.14.0 → 0.16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -8
  3. package/SECURITY.md +10 -3
  4. package/browser-extension/browser-operations.js +515 -0
  5. package/browser-extension/devtools-input.js +17 -2
  6. package/browser-extension/manifest.json +2 -2
  7. package/browser-extension/page-automation.js +245 -60
  8. package/browser-extension/pairing.js +1 -1
  9. package/browser-extension/service-worker.js +151 -354
  10. package/docs/ARCHITECTURE.md +6 -4
  11. package/docs/AUDIT.md +24 -1
  12. package/docs/LOCAL_AUTOMATION.md +10 -10
  13. package/docs/LOGGING.md +7 -1
  14. package/docs/OPERATIONS.md +13 -5
  15. package/docs/POLICY_REFERENCE.md +88 -0
  16. package/docs/TESTING.md +9 -2
  17. package/package.json +16 -3
  18. package/scripts/coverage-check.mjs +108 -0
  19. package/scripts/generate-policy-reference.mjs +95 -0
  20. package/src/local/agent-context.mjs +1 -103
  21. package/src/local/app-automation.mjs +7 -9
  22. package/src/local/browser-bridge.mjs +69 -143
  23. package/src/local/browser-extension-protocol.mjs +75 -0
  24. package/src/local/browser-pairing-store.mjs +83 -0
  25. package/src/local/call-registry.mjs +113 -0
  26. package/src/local/capability-ranking.mjs +103 -0
  27. package/src/local/cli-local-admin.mjs +308 -0
  28. package/src/local/cli-options.mjs +151 -0
  29. package/src/local/cli-policy.mjs +77 -0
  30. package/src/local/cli.mjs +16 -507
  31. package/src/local/errors.mjs +122 -0
  32. package/src/local/git-service.mjs +88 -0
  33. package/src/local/lifecycle.mjs +64 -0
  34. package/src/local/log.mjs +50 -19
  35. package/src/local/managed-job-plan.mjs +235 -0
  36. package/src/local/managed-jobs.mjs +16 -220
  37. package/src/local/observability.mjs +83 -0
  38. package/src/local/policy.mjs +148 -0
  39. package/src/local/process-execution.mjs +153 -0
  40. package/src/local/process-sessions.mjs +10 -20
  41. package/src/local/process-tracker.mjs +55 -0
  42. package/src/local/runtime.mjs +154 -672
  43. package/src/local/service.mjs +8 -3
  44. package/src/local/stdio.mjs +3 -11
  45. package/src/local/tool-executor.mjs +102 -0
  46. package/src/local/tools.mjs +21 -104
  47. package/src/local/workspace-file-service.mjs +451 -0
  48. package/src/shared/policy-contract.json +54 -0
  49. package/src/shared/tool-catalog.json +11 -11
  50. package/src/worker/index.ts +69 -524
@@ -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
  }
@@ -58,9 +58,9 @@ See [Session instructions, skills, commands, and capability discovery](AGENT_CON
58
58
 
59
59
  ### Browser extension and machine broker
60
60
 
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.
61
+ `BrowserBridgeManager` owns connection orchestration for a loopback HTTP/WebSocket broker. Extension version/capability parsing lives in `browser-extension-protocol.mjs`; pairing files and local HTML/Host/Origin helpers live in `browser-pairing-store.mjs`. 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
 
@@ -86,7 +86,9 @@ Local resource registrations remain in owner-only state and are reloaded for eve
86
86
 
87
87
  ### Canonical policy contracts
88
88
 
89
- Named profiles are normalized to their complete capability sets. In particular, `full` always means write enabled, shell execution, unrestricted paths, complete parent environment, absolute-path display, and every catalog tool. CLI flags that alter an individual capability deliberately change the profile identity to `custom`. Policy revision 3 repairs stale or manually edited named-profile fields rather than allowing a misleading partially restricted `full` label.
89
+ Policy revision 4 is loaded from one shared JSON contract by both local and Worker policy evaluators. Tool advertisement and execution authorization use catalog availability classes; managers receive the same authorizer instead of reimplementing profile conditionals. Read-only job/resource inspection is `always`, job cancellation is write-gated, and starting a persistent job requires `write+direct-exec`.
90
+
91
+ Named profiles are normalized to their complete capability sets. In particular, `full` always means write enabled, shell execution, unrestricted paths, complete parent environment, absolute-path display, and every catalog tool. CLI flags that alter an individual capability deliberately change the profile identity to `custom`. Policy revision 4 repairs stale or manually edited named-profile fields rather than allowing a misleading partially restricted `full` label, and applies compound availability requirements from the shared contract.
90
92
 
91
93
  The full-only `generate_ssh_key_resource` operation is implemented locally. The Worker only filters and relays its shared catalog definition. Local generation uses `ssh-keygen`, verifies public/private correspondence, registers the private file through the same owner-only state transaction as the CLI, and rolls back a newly created pair if state persistence fails.
92
94
 
@@ -111,7 +113,7 @@ The daemon attachment deliberately omits workspace path/name/hash and process ID
111
113
 
112
114
  ### Autostart layer
113
115
 
114
- The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions. Credentials are not embedded in service definitions; the daemon loads owner-only state. The exact policy is stored in owner-only state. launchd/systemd definitions contain the workspace/state-root selectors, a `warn` log-level setting, and a sanitized absolute-only PATH captured at installation so background `full` mode can resolve the same developer tools without accepting relative PATH entries.
116
+ The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions. Credentials are not embedded in service definitions; the daemon loads owner-only state. The exact policy is stored in owner-only state. launchd/systemd definitions contain the workspace/state-root selectors, `warn` plus JSON log settings, and a sanitized absolute-only PATH captured at installation so background `full` mode can resolve the same developer tools without accepting relative PATH entries.
115
117
 
116
118
  The platform adapters normalize launchd, systemd, and Windows Scheduled Task operations to one `{ok, provider}` result contract. Removal is not a provider-specific sequence: `service-lifecycle.mjs` first stops the provider, then every verified workspace daemon in scope, and only then removes the definition. A failed stop or unverifiable process prevents definition/state deletion.
117
119