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.
- package/CHANGELOG.md +19 -0
- package/README.md +6 -6
- package/SECURITY.md +3 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/AUDIT.md +13 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/OPERATIONS.md +5 -4
- package/docs/TESTING.md +2 -2
- package/package.json +5 -3
- package/src/local/browser-bridge.mjs +64 -8
- package/src/local/cli.mjs +16 -2
- package/src/local/service.mjs +7 -3
- package/src/shared/tool-catalog.json +7 -7
- package/src/worker/index.ts +1 -1
|
@@ -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 &&
|
|
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
|
|
36
|
-
return
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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 ||
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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:
|
|
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(() => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
508
|
-
return
|
|
304
|
+
function dispatch(method, params, state) {
|
|
305
|
+
return browserOperations().dispatch(method, params, state);
|
|
509
306
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
|