machine-bridge-mcp 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +41 -12
- package/SECURITY.md +5 -5
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +151 -0
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/page-automation.js +540 -75
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +158 -258
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/AUDIT.md +13 -1
- package/docs/LOCAL_AUTOMATION.md +18 -14
- package/docs/OPERATIONS.md +17 -9
- package/docs/TESTING.md +4 -4
- package/package.json +10 -4
- package/src/local/agent-context.mjs +1 -1
- package/src/local/browser-bridge.mjs +245 -91
- package/src/local/browser-command.mjs +126 -0
- package/src/local/cli.mjs +37 -5
- package/src/local/runtime.mjs +2 -0
- package/src/local/service.mjs +7 -3
- package/src/shared/tool-catalog.json +214 -8
- package/src/worker/index.ts +14 -8
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
if (globalThis.__machineBridgeBrowserOperations) return;
|
|
3
|
+
|
|
4
|
+
const MAX_ACCESSIBLE_FRAMES = 64;
|
|
5
|
+
|
|
6
|
+
async function dispatch(method, params, state) {
|
|
7
|
+
if (method === "list_tabs") return listTabs(params);
|
|
8
|
+
if (method === "manage_tabs") return manageTabs(params);
|
|
9
|
+
if (method === "wait") return browserWait(params, state);
|
|
10
|
+
if (method === "get_source") return getSource(params, state);
|
|
11
|
+
if (method === "inspect_page") return inspectPage(params, state);
|
|
12
|
+
if (method === "action") return browserAction(params, state);
|
|
13
|
+
if (method === "fill_form") return fillForm(params, state);
|
|
14
|
+
if (method === "upload_files") return uploadFiles(params);
|
|
15
|
+
if (method === "screenshot") return screenshot(params);
|
|
16
|
+
throw new Error(`unknown browser method: ${method}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function listTabs(params) {
|
|
20
|
+
const query = params.currentWindow ? { currentWindow: true } : {};
|
|
21
|
+
const tabs = await chrome.tabs.query(query);
|
|
22
|
+
return {
|
|
23
|
+
tabs: tabs
|
|
24
|
+
.filter((tab) => params.includePinned !== false || !tab.pinned)
|
|
25
|
+
.map((tab) => ({
|
|
26
|
+
id: tab.id,
|
|
27
|
+
window_id: tab.windowId,
|
|
28
|
+
active: tab.active,
|
|
29
|
+
pinned: tab.pinned,
|
|
30
|
+
audible: tab.audible,
|
|
31
|
+
discarded: tab.discarded,
|
|
32
|
+
status: tab.status,
|
|
33
|
+
title: tab.title || "",
|
|
34
|
+
url: tab.url || "",
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async function manageTabs(params) {
|
|
41
|
+
if (params.action === "new") {
|
|
42
|
+
const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: params.active !== false });
|
|
43
|
+
return { action: "new", ...publicTab(tab) };
|
|
44
|
+
}
|
|
45
|
+
const tab = await chrome.tabs.get(params.tabId);
|
|
46
|
+
if (params.action === "activate") {
|
|
47
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
48
|
+
await chrome.windows.update(tab.windowId, { focused: true });
|
|
49
|
+
return { action: "activate", ...publicTab(await chrome.tabs.get(tab.id)) };
|
|
50
|
+
}
|
|
51
|
+
if (params.action === "close") {
|
|
52
|
+
const result = { action: "close", closed: true, ...publicTab(tab) };
|
|
53
|
+
await chrome.tabs.remove(tab.id);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
throw new Error("unsupported browser tab action");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function browserWait(params, state) {
|
|
60
|
+
const tab = await resolveTab(params.tabId);
|
|
61
|
+
const deadline = Date.now() + Math.max(1, Number(params.timeoutMs) || 30000);
|
|
62
|
+
let last = null;
|
|
63
|
+
while (Date.now() <= deadline) {
|
|
64
|
+
throwIfCancelled(state);
|
|
65
|
+
const current = await chrome.tabs.get(tab.id);
|
|
66
|
+
const urlMatched = !params.urlContains || String(current.url || "").includes(params.urlContains);
|
|
67
|
+
const needsPage = Boolean(params.selector || params.text || params.loadState);
|
|
68
|
+
let page = { matched: true, ready_state: current.status || "" };
|
|
69
|
+
if (needsPage) {
|
|
70
|
+
try {
|
|
71
|
+
assertPageTab(current);
|
|
72
|
+
const [execution] = await executePageAutomation(scriptTarget(current.id, params.frameId, false), "checkWait", params);
|
|
73
|
+
page = execution?.result || { matched: false };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
|
|
76
|
+
if (message.includes("invalid CSS selector") || message.includes("selector matched")) throw new Error(message);
|
|
77
|
+
page = { matched: false, error: message };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
last = { url_matched: urlMatched, ...page };
|
|
81
|
+
if (urlMatched && page.matched === true) {
|
|
82
|
+
return { ok: true, tab_id: current.id, title: current.title || "", url: current.url || "", condition: last };
|
|
83
|
+
}
|
|
84
|
+
await delay(200);
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`browser wait timed out; last condition: ${JSON.stringify(last || {})}`);
|
|
87
|
+
}
|
|
88
|
+
function scriptTarget(tabId, frameId, allFrames) {
|
|
89
|
+
if (Number.isInteger(frameId)) return { tabId, frameIds: [frameId] };
|
|
90
|
+
if (allFrames) return { tabId, allFrames: true };
|
|
91
|
+
return { tabId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveTab(tabId) {
|
|
95
|
+
if (Number.isInteger(tabId)) return chrome.tabs.get(tabId);
|
|
96
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
97
|
+
if (!tab?.id) throw new Error("no active browser tab");
|
|
98
|
+
return tab;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function getSource(params, state) {
|
|
102
|
+
const tab = await resolveTab(params.tabId);
|
|
103
|
+
assertPageTab(tab);
|
|
104
|
+
const maxBytes = Math.max(1, Number(params.maxBytes) || 1024 * 1024);
|
|
105
|
+
const selection = await discoverPageFrames(tab.id, params.frameId, params.allFrames === true);
|
|
106
|
+
let remainingBytes = maxBytes;
|
|
107
|
+
const frames = [];
|
|
108
|
+
for (let index = 0; index < selection.frames.length && remainingBytes > 0; index += 1) {
|
|
109
|
+
throwIfCancelled(state);
|
|
110
|
+
const remainingFrames = selection.frames.length - index;
|
|
111
|
+
const frameBudget = Math.max(1, Math.floor(remainingBytes / remainingFrames));
|
|
112
|
+
const [execution] = await chrome.scripting.executeScript({
|
|
113
|
+
target: { tabId: tab.id, frameIds: [selection.frames[index].frameId] },
|
|
114
|
+
func: boundedDocumentSource,
|
|
115
|
+
args: [frameBudget],
|
|
116
|
+
});
|
|
117
|
+
const result = execution?.result || { source: "", bytes: 0, returned_bytes: 0, truncated: true, url: selection.frames[index].url };
|
|
118
|
+
const returnedBytes = Math.max(0, Math.min(frameBudget, Number(result.returned_bytes ?? result.bytes) || 0));
|
|
119
|
+
remainingBytes -= returnedBytes;
|
|
120
|
+
frames.push({ frame_id: execution?.frameId ?? selection.frames[index].frameId, ...result, returned_bytes: returnedBytes, bytes: returnedBytes });
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
tab_id: tab.id,
|
|
124
|
+
title: tab.title || "",
|
|
125
|
+
url: tab.url || "",
|
|
126
|
+
frames,
|
|
127
|
+
returned_bytes: maxBytes - remainingBytes,
|
|
128
|
+
max_bytes: maxBytes,
|
|
129
|
+
frames_truncated: selection.truncated || frames.length < selection.frames.length,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function inspectPage(params, state) {
|
|
134
|
+
const tab = await resolveTab(params.tabId);
|
|
135
|
+
assertPageTab(tab);
|
|
136
|
+
const maxElements = Math.max(1, Number(params.maxElements) || 300);
|
|
137
|
+
const selection = await discoverPageFrames(tab.id, params.frameId, params.allFrames === true, maxElements);
|
|
138
|
+
let remainingElements = maxElements;
|
|
139
|
+
const frames = [];
|
|
140
|
+
for (let index = 0; index < selection.frames.length && remainingElements > 0; index += 1) {
|
|
141
|
+
throwIfCancelled(state);
|
|
142
|
+
const remainingFrames = selection.frames.length - index;
|
|
143
|
+
const frameBudget = Math.max(1, Math.floor(remainingElements / remainingFrames));
|
|
144
|
+
const [execution] = await executePageAutomation(
|
|
145
|
+
{ tabId: tab.id, frameIds: [selection.frames[index].frameId] },
|
|
146
|
+
"inspect",
|
|
147
|
+
{ maxElements: frameBudget, includeValues: params.includeValues === true },
|
|
148
|
+
);
|
|
149
|
+
const result = execution?.result || { elements: [], truncated: true };
|
|
150
|
+
const used = Math.min(frameBudget, Array.isArray(result.elements) ? result.elements.length : 0);
|
|
151
|
+
remainingElements -= used;
|
|
152
|
+
frames.push({ frame_id: execution?.frameId ?? selection.frames[index].frameId, ...result });
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
tab_id: tab.id,
|
|
156
|
+
title: tab.title || "",
|
|
157
|
+
url: tab.url || "",
|
|
158
|
+
frames,
|
|
159
|
+
total_elements: maxElements - remainingElements,
|
|
160
|
+
max_elements: maxElements,
|
|
161
|
+
frames_truncated: selection.truncated,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function discoverPageFrames(tabId, frameId, allFrames, resultBudget = MAX_ACCESSIBLE_FRAMES) {
|
|
166
|
+
if (Number.isInteger(frameId)) return { frames: [{ frameId, url: "" }], truncated: false };
|
|
167
|
+
if (!allFrames) return { frames: [{ frameId: 0, url: "" }], truncated: false };
|
|
168
|
+
const executions = await chrome.scripting.executeScript({
|
|
169
|
+
target: { tabId, allFrames: true },
|
|
170
|
+
func: () => ({ url: String(location.href || "").slice(0, 8192) }),
|
|
171
|
+
});
|
|
172
|
+
const ordered = executions
|
|
173
|
+
.filter((item) => Number.isInteger(item.frameId))
|
|
174
|
+
.map((item) => ({ frameId: item.frameId, url: item.result?.url || "" }))
|
|
175
|
+
.sort((left, right) => (left.frameId === 0 ? -1 : right.frameId === 0 ? 1 : left.frameId - right.frameId));
|
|
176
|
+
const limit = Math.max(1, Math.min(MAX_ACCESSIBLE_FRAMES, Number(resultBudget) || 1));
|
|
177
|
+
return { frames: ordered.slice(0, limit), truncated: ordered.length > limit };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function boundedDocumentSource(limit) {
|
|
181
|
+
const maxBytes = Math.max(1, Number(limit) || 1);
|
|
182
|
+
const encoder = new TextEncoder();
|
|
183
|
+
const decoder = new TextDecoder();
|
|
184
|
+
const chunks = [];
|
|
185
|
+
const VOID_ELEMENTS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
186
|
+
const MAX_NODES = 100000;
|
|
187
|
+
const MAX_ATTRIBUTES = 256;
|
|
188
|
+
const MAX_ATTRIBUTE_CHARS = 4096;
|
|
189
|
+
const MAX_TEXT_CHARS = 32768;
|
|
190
|
+
let returnedBytes = 0;
|
|
191
|
+
let budgetExhausted = false;
|
|
192
|
+
let safetyTruncated = false;
|
|
193
|
+
let visitedNodes = 0;
|
|
194
|
+
let openShadowRoots = 0;
|
|
195
|
+
|
|
196
|
+
const append = (raw) => {
|
|
197
|
+
if (budgetExhausted) return false;
|
|
198
|
+
let text = String(raw || "");
|
|
199
|
+
const remaining = maxBytes - returnedBytes;
|
|
200
|
+
if (remaining <= 0) { budgetExhausted = true; return false; }
|
|
201
|
+
const maxChars = Math.max(1024, remaining);
|
|
202
|
+
if (text.length > maxChars) { text = text.slice(0, maxChars); safetyTruncated = true; }
|
|
203
|
+
const bytes = encoder.encode(text);
|
|
204
|
+
if (bytes.byteLength <= remaining) {
|
|
205
|
+
chunks.push(text);
|
|
206
|
+
returnedBytes += bytes.byteLength;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
chunks.push(decoder.decode(bytes.slice(0, remaining)));
|
|
210
|
+
returnedBytes = maxBytes;
|
|
211
|
+
budgetExhausted = true;
|
|
212
|
+
return false;
|
|
213
|
+
};
|
|
214
|
+
const bounded = (value, maxChars) => {
|
|
215
|
+
const text = String(value || "");
|
|
216
|
+
if (text.length > maxChars) safetyTruncated = true;
|
|
217
|
+
return text.slice(0, maxChars);
|
|
218
|
+
};
|
|
219
|
+
const escapeText = (value) => bounded(value, MAX_TEXT_CHARS).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
220
|
+
const escapeAttribute = (value) => bounded(value, MAX_ATTRIBUTE_CHARS).replaceAll("&", "&").replaceAll('"', """);
|
|
221
|
+
|
|
222
|
+
if (document.doctype) append(`<!DOCTYPE ${bounded(document.doctype.name || "html", 100)}>\n`);
|
|
223
|
+
const stack = [...document.childNodes].reverse().map((node) => ({ node }));
|
|
224
|
+
while (stack.length && !budgetExhausted) {
|
|
225
|
+
if (visitedNodes >= MAX_NODES) { safetyTruncated = true; break; }
|
|
226
|
+
const item = stack.pop();
|
|
227
|
+
if (item.raw) { append(item.raw); continue; }
|
|
228
|
+
if (item.close) { append(item.close); continue; }
|
|
229
|
+
const node = item.node;
|
|
230
|
+
if (!node || node.nodeType === 10) continue;
|
|
231
|
+
visitedNodes += 1;
|
|
232
|
+
if (node.nodeType === 1) {
|
|
233
|
+
const tag = bounded(node.tagName || "div", 100).toLowerCase();
|
|
234
|
+
if (!append(`<${tag}`)) break;
|
|
235
|
+
const attributes = node.attributes || [];
|
|
236
|
+
const attributeCount = Math.min(Number(attributes.length) || 0, MAX_ATTRIBUTES);
|
|
237
|
+
for (let attributeIndex = 0; attributeIndex < attributeCount; attributeIndex += 1) {
|
|
238
|
+
const attribute = attributes[attributeIndex] || attributes.item?.(attributeIndex);
|
|
239
|
+
if (!attribute) continue;
|
|
240
|
+
if (!append(` ${bounded(attribute.name, 200)}="${escapeAttribute(attribute.value)}"`)) break;
|
|
241
|
+
}
|
|
242
|
+
if ((Number(attributes.length) || 0) > MAX_ATTRIBUTES) safetyTruncated = true;
|
|
243
|
+
if (!append(">")) break;
|
|
244
|
+
if (VOID_ELEMENTS.has(tag)) continue;
|
|
245
|
+
stack.push({ close: `</${tag}>` });
|
|
246
|
+
const shadowChildren = node.shadowRoot?.childNodes || [];
|
|
247
|
+
if ((Number(shadowChildren.length) || 0) > 0) {
|
|
248
|
+
openShadowRoots += 1;
|
|
249
|
+
stack.push({ raw: "</template>" });
|
|
250
|
+
for (let index = (Number(shadowChildren.length) || 0) - 1; index >= 0; index -= 1) stack.push({ node: shadowChildren[index] || shadowChildren.item?.(index) });
|
|
251
|
+
stack.push({ raw: '<template data-machine-bridge-shadow-root="open">' });
|
|
252
|
+
}
|
|
253
|
+
const children = node.content?.childNodes || node.childNodes || [];
|
|
254
|
+
for (let index = (Number(children.length) || 0) - 1; index >= 0; index -= 1) stack.push({ node: children[index] || children.item?.(index) });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (node.nodeType === 3) {
|
|
258
|
+
const parentTag = String(node.parentElement?.tagName || "").toLowerCase();
|
|
259
|
+
append(parentTag === "script" || parentTag === "style" ? bounded(node.data, MAX_TEXT_CHARS) : escapeText(node.data));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (node.nodeType === 8) append(`<!--${bounded(node.data, MAX_TEXT_CHARS).replaceAll("--", "- -")}-->`);
|
|
263
|
+
}
|
|
264
|
+
if (safetyTruncated && !budgetExhausted) append("<!-- machine-bridge source truncated by safety limit -->");
|
|
265
|
+
return {
|
|
266
|
+
source: chunks.join(""),
|
|
267
|
+
bytes: returnedBytes,
|
|
268
|
+
returned_bytes: returnedBytes,
|
|
269
|
+
truncated: budgetExhausted || safetyTruncated || stack.length > 0,
|
|
270
|
+
visited_nodes: visitedNodes,
|
|
271
|
+
node_limit: MAX_NODES,
|
|
272
|
+
open_shadow_roots: openShadowRoots,
|
|
273
|
+
url: String(location.href || "").slice(0, 8192),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function browserAction(params, state) {
|
|
278
|
+
const tab = await resolveTab(params.tabId);
|
|
279
|
+
if (params.action === "navigate") {
|
|
280
|
+
if (!params.url) throw new Error("navigate requires url");
|
|
281
|
+
const waiter = beginTabWait(tab.id, params.waitFor, state?.timeoutMs, state);
|
|
282
|
+
try {
|
|
283
|
+
const updated = await chrome.tabs.update(tab.id, { url: params.url });
|
|
284
|
+
await waiter.promise;
|
|
285
|
+
return publicTab(await chrome.tabs.get(updated.id));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
waiter.cancel();
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (params.action === "reload") {
|
|
292
|
+
const waiter = beginTabWait(tab.id, params.waitFor, state?.timeoutMs, state);
|
|
293
|
+
try {
|
|
294
|
+
await chrome.tabs.reload(tab.id);
|
|
295
|
+
await waiter.promise;
|
|
296
|
+
return publicTab(await chrome.tabs.get(tab.id));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
waiter.cancel();
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (params.action === "back" || params.action === "forward") {
|
|
303
|
+
const waiter = beginTabWait(tab.id, params.waitFor, state?.timeoutMs, state);
|
|
304
|
+
try {
|
|
305
|
+
if (params.action === "back") await chrome.tabs.goBack(tab.id);
|
|
306
|
+
else await chrome.tabs.goForward(tab.id);
|
|
307
|
+
await waiter.promise;
|
|
308
|
+
return publicTab(await chrome.tabs.get(tab.id));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
waiter.cancel();
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
assertPageTab(tab);
|
|
315
|
+
const waiter = beginTabWait(tab.id, params.waitFor, state?.timeoutMs, state);
|
|
316
|
+
let result;
|
|
317
|
+
try {
|
|
318
|
+
result = await performPageAction(tab, params);
|
|
319
|
+
await waiter.promise;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
waiter.cancel();
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const current = await chrome.tabs.get(tab.id).catch(() => tab);
|
|
325
|
+
return { tab_id: current.id, title: current.title || "", url: current.url || "", ...result };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function performPageAction(tab, params) {
|
|
329
|
+
const trustedActions = new Set(["click", "double_click", "hover", "press", "type_text"]);
|
|
330
|
+
if (params.inputMode === "trusted" && !trustedActions.has(params.action)) {
|
|
331
|
+
throw new Error("trusted input is unavailable for this action");
|
|
332
|
+
}
|
|
333
|
+
const wantsTrusted = params.inputMode !== "dom" && trustedActions.has(params.action);
|
|
334
|
+
const topFrame = !Number.isInteger(params.frameId) || params.frameId === 0;
|
|
335
|
+
if (wantsTrusted && !topFrame && params.inputMode === "trusted") {
|
|
336
|
+
throw new Error("trusted input currently requires the top frame; use input_mode=dom for a subframe");
|
|
337
|
+
}
|
|
338
|
+
if (wantsTrusted && topFrame) {
|
|
339
|
+
const [prepared] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "prepareAction", params);
|
|
340
|
+
try {
|
|
341
|
+
const api = globalThis.__machineBridgeDevtoolsInput;
|
|
342
|
+
if (!api?.perform) {
|
|
343
|
+
const unavailable = new Error("trusted input module is unavailable");
|
|
344
|
+
Object.defineProperty(unavailable, "safeToFallback", { value: true });
|
|
345
|
+
throw unavailable;
|
|
346
|
+
}
|
|
347
|
+
await api.perform(tab.id, params.action, {
|
|
348
|
+
point: prepared.result?.point,
|
|
349
|
+
key: params.key || params.value || "Enter",
|
|
350
|
+
text: params.value || "",
|
|
351
|
+
});
|
|
352
|
+
return { ...prepared.result, input_mode: "trusted", trusted_input_fallback: false };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (params.inputMode === "trusted") throw error;
|
|
355
|
+
if (error?.safeToFallback !== true) {
|
|
356
|
+
const detail = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
|
|
357
|
+
throw new Error(`trusted browser input may have been partially dispatched; the action outcome is unknown. Inspect the page before retrying. (${detail})`);
|
|
358
|
+
}
|
|
359
|
+
const [fallback] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
|
|
360
|
+
return {
|
|
361
|
+
...fallback.result,
|
|
362
|
+
input_mode: "dom",
|
|
363
|
+
trusted_input_fallback: true,
|
|
364
|
+
fallback_reason: String(error?.message || error).slice(0, 500),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
|
|
369
|
+
return { ...execution.result, input_mode: "dom", trusted_input_fallback: false };
|
|
370
|
+
}
|
|
371
|
+
async function fillForm(params, state) {
|
|
372
|
+
const tab = await resolveTab(params.tabId);
|
|
373
|
+
assertPageTab(tab);
|
|
374
|
+
const waiter = beginTabWait(tab.id, params.waitFor, state?.timeoutMs, state);
|
|
375
|
+
let execution;
|
|
376
|
+
try {
|
|
377
|
+
[execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "fillForm", params);
|
|
378
|
+
await waiter.promise;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
waiter.cancel();
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function uploadFiles(params) {
|
|
387
|
+
const tab = await resolveTab(params.tabId);
|
|
388
|
+
assertPageTab(tab);
|
|
389
|
+
const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "uploadFiles", params);
|
|
390
|
+
return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function screenshot(params) {
|
|
394
|
+
const tab = await resolveTab(params.tabId);
|
|
395
|
+
const [previousActive] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
396
|
+
const changedActiveTab = previousActive?.id !== tab.id;
|
|
397
|
+
if (changedActiveTab) await chrome.tabs.update(tab.id, { active: true });
|
|
398
|
+
try {
|
|
399
|
+
const data = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
400
|
+
format: params.format === "jpeg" ? "jpeg" : "png",
|
|
401
|
+
quality: Number(params.quality) || 90,
|
|
402
|
+
});
|
|
403
|
+
return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", data };
|
|
404
|
+
} finally {
|
|
405
|
+
if (changedActiveTab && previousActive?.id) {
|
|
406
|
+
const [currentActive] = await chrome.tabs.query({ active: true, windowId: tab.windowId }).catch(() => []);
|
|
407
|
+
if (currentActive?.id === tab.id) await chrome.tabs.update(previousActive.id, { active: true }).catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function publicTab(tab) {
|
|
413
|
+
return { tab_id: tab.id, window_id: tab.windowId, title: tab.title || "", url: tab.url || "", status: tab.status || "" };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function assertPageTab(tab) {
|
|
417
|
+
const url = String(tab.url || "");
|
|
418
|
+
if (!/^(https?|file):/i.test(url)) throw new Error("this page cannot be scripted by a browser extension");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function beginTabWait(tabId, mode, requestTimeoutMs = 30000, state = null) {
|
|
422
|
+
if (!mode || mode === "none") return { promise: Promise.resolve(), cancel() {} };
|
|
423
|
+
const waitTimeoutMs = Math.max(1000, boundedRequestTimeout(requestTimeoutMs) - 1000);
|
|
424
|
+
let settled = false;
|
|
425
|
+
let navigationStarted = false;
|
|
426
|
+
let pollTimer = null;
|
|
427
|
+
let cancellationTimer = null;
|
|
428
|
+
let timeout = null;
|
|
429
|
+
let lastPollError = "";
|
|
430
|
+
let resolveWait;
|
|
431
|
+
let rejectWait;
|
|
432
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
433
|
+
resolveWait = resolvePromise;
|
|
434
|
+
rejectWait = rejectPromise;
|
|
435
|
+
});
|
|
436
|
+
const cleanup = () => {
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
clearTimeout(pollTimer);
|
|
439
|
+
clearTimeout(cancellationTimer);
|
|
440
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
441
|
+
chrome.tabs.onRemoved?.removeListener(removedListener);
|
|
442
|
+
};
|
|
443
|
+
const settle = (error = null) => {
|
|
444
|
+
if (settled) return;
|
|
445
|
+
settled = true;
|
|
446
|
+
cleanup();
|
|
447
|
+
if (error) rejectWait(error); else resolveWait();
|
|
448
|
+
};
|
|
449
|
+
const pollReadyState = async () => {
|
|
450
|
+
if (settled || !navigationStarted || mode !== "domcontentloaded") return;
|
|
451
|
+
try {
|
|
452
|
+
const [execution] = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.readyState });
|
|
453
|
+
if (["interactive", "complete"].includes(execution?.result)) {
|
|
454
|
+
settle();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
lastPollError = String(error?.message || error).replace(/\s+/g, " ").slice(0, 300);
|
|
459
|
+
}
|
|
460
|
+
pollTimer = setTimeout(pollReadyState, 100);
|
|
461
|
+
};
|
|
462
|
+
const pollCancellation = () => {
|
|
463
|
+
if (settled) return;
|
|
464
|
+
if (state?.cancelled) { settle(new Error("browser request cancelled")); return; }
|
|
465
|
+
cancellationTimer = setTimeout(pollCancellation, 100);
|
|
466
|
+
};
|
|
467
|
+
const removedListener = (removedId) => {
|
|
468
|
+
if (removedId === tabId) settle(new Error("browser tab closed during navigation wait"));
|
|
469
|
+
};
|
|
470
|
+
const listener = (updatedId, changeInfo, tab) => {
|
|
471
|
+
if (updatedId !== tabId || settled) return;
|
|
472
|
+
if (changeInfo.status === "loading" || typeof changeInfo.url === "string") navigationStarted = true;
|
|
473
|
+
if (!navigationStarted) return;
|
|
474
|
+
if (mode === "complete" && (changeInfo.status === "complete" || (typeof changeInfo.url === "string" && tab.status === "complete"))) settle();
|
|
475
|
+
if (mode === "domcontentloaded") void pollReadyState();
|
|
476
|
+
};
|
|
477
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
478
|
+
chrome.tabs.onRemoved?.addListener(removedListener);
|
|
479
|
+
pollCancellation();
|
|
480
|
+
timeout = setTimeout(() => settle(new Error(`browser navigation wait timed out${lastPollError ? `: ${lastPollError}` : ""}`)), waitTimeoutMs);
|
|
481
|
+
return { promise, cancel: () => settle() };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function executePageAutomation(target, method, params) {
|
|
485
|
+
return chrome.scripting.executeScript({ target, files: ["page-automation.js"] })
|
|
486
|
+
.then(() => chrome.scripting.executeScript({
|
|
487
|
+
target,
|
|
488
|
+
func: (operation, payload) => {
|
|
489
|
+
const api = globalThis.__machineBridgePageAutomation;
|
|
490
|
+
if (!api || typeof api[operation] !== "function") throw new Error("page automation module is unavailable");
|
|
491
|
+
return api[operation](payload);
|
|
492
|
+
},
|
|
493
|
+
args: [method, params],
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function delay(ms) {
|
|
498
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function boundedRequestTimeout(value) {
|
|
502
|
+
const timeout = Number(value);
|
|
503
|
+
if (!Number.isFinite(timeout)) return 30000;
|
|
504
|
+
return Math.max(1000, Math.min(185000, Math.floor(timeout)));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function throwIfCancelled(state) {
|
|
508
|
+
if (state?.cancelled) throw new Error("browser request cancelled");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
Object.defineProperty(globalThis, "__machineBridgeBrowserOperations", {
|
|
512
|
+
value: Object.freeze({ dispatch, boundedRequestTimeout, boundedDocumentSource }),
|
|
513
|
+
configurable: false,
|
|
514
|
+
});
|
|
515
|
+
})();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
if (globalThis.__machineBridgeDevtoolsInput) return;
|
|
3
|
+
|
|
4
|
+
const MODIFIERS = Object.freeze({ Alt: 1, Control: 2, Meta: 4, Shift: 8 });
|
|
5
|
+
const tabQueues = new Map();
|
|
6
|
+
const KEY_DATA = Object.freeze({
|
|
7
|
+
Enter: { code: "Enter", virtualKeyCode: 13 },
|
|
8
|
+
Tab: { code: "Tab", virtualKeyCode: 9 },
|
|
9
|
+
Escape: { code: "Escape", virtualKeyCode: 27 },
|
|
10
|
+
Backspace: { code: "Backspace", virtualKeyCode: 8 },
|
|
11
|
+
Delete: { code: "Delete", virtualKeyCode: 46 },
|
|
12
|
+
ArrowLeft: { code: "ArrowLeft", virtualKeyCode: 37 },
|
|
13
|
+
ArrowUp: { code: "ArrowUp", virtualKeyCode: 38 },
|
|
14
|
+
ArrowRight: { code: "ArrowRight", virtualKeyCode: 39 },
|
|
15
|
+
ArrowDown: { code: "ArrowDown", virtualKeyCode: 40 },
|
|
16
|
+
Home: { code: "Home", virtualKeyCode: 36 },
|
|
17
|
+
End: { code: "End", virtualKeyCode: 35 },
|
|
18
|
+
PageUp: { code: "PageUp", virtualKeyCode: 33 },
|
|
19
|
+
PageDown: { code: "PageDown", virtualKeyCode: 34 },
|
|
20
|
+
Space: { code: "Space", virtualKeyCode: 32, key: " " },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function perform(tabId, action, details = {}) {
|
|
24
|
+
if (!Number.isInteger(tabId) || tabId < 1) throw new Error("trusted input requires a valid tab");
|
|
25
|
+
const previous = tabQueues.get(tabId) || Promise.resolve();
|
|
26
|
+
const current = previous.catch(() => {}).then(() => withDebugger(tabId, async (send) => {
|
|
27
|
+
if (action === "click") return mouseClick(send, details.point, 1);
|
|
28
|
+
if (action === "double_click") return mouseClick(send, details.point, 2);
|
|
29
|
+
if (action === "hover") return mouseMove(send, details.point);
|
|
30
|
+
if (action === "press") return pressKey(send, details.key || "Enter");
|
|
31
|
+
if (action === "type_text") return send("Input.insertText", { text: String(details.text ?? "") });
|
|
32
|
+
throw new Error(`trusted input does not support '${action}'`);
|
|
33
|
+
}));
|
|
34
|
+
tabQueues.set(tabId, current);
|
|
35
|
+
try { return await current; }
|
|
36
|
+
finally { if (tabQueues.get(tabId) === current) tabQueues.delete(tabId); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function withDebugger(tabId, operation) {
|
|
40
|
+
const target = { tabId };
|
|
41
|
+
let attached = false;
|
|
42
|
+
let dispatchStarted = false;
|
|
43
|
+
try {
|
|
44
|
+
await chrome.debugger.attach(target, "1.3");
|
|
45
|
+
attached = true;
|
|
46
|
+
const send = (method, params = {}) => {
|
|
47
|
+
dispatchStarted = true;
|
|
48
|
+
return chrome.debugger.sendCommand(target, method, params);
|
|
49
|
+
};
|
|
50
|
+
return await operation(send);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.machineBridgeTrustedInput === true) throw error;
|
|
53
|
+
throw trustedInputError(error, { safeToFallback: !dispatchStarted, dispatchStarted });
|
|
54
|
+
} finally {
|
|
55
|
+
if (attached) {
|
|
56
|
+
try { await chrome.debugger.detach(target); } catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function mouseMove(send, point) {
|
|
62
|
+
const { x, y } = normalizePoint(point);
|
|
63
|
+
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function mouseClick(send, point, clickCount) {
|
|
67
|
+
const { x, y } = normalizePoint(point);
|
|
68
|
+
await mouseMove(send, { x, y });
|
|
69
|
+
for (let count = 1; count <= clickCount; count += 1) {
|
|
70
|
+
await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: count });
|
|
71
|
+
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: count });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function pressKey(send, shortcut) {
|
|
76
|
+
const parsed = parseShortcut(shortcut);
|
|
77
|
+
const down = {
|
|
78
|
+
type: parsed.text ? "keyDown" : "rawKeyDown",
|
|
79
|
+
key: parsed.key,
|
|
80
|
+
code: parsed.code,
|
|
81
|
+
modifiers: parsed.modifiers,
|
|
82
|
+
windowsVirtualKeyCode: parsed.virtualKeyCode,
|
|
83
|
+
nativeVirtualKeyCode: parsed.virtualKeyCode,
|
|
84
|
+
...(parsed.text ? { text: parsed.text, unmodifiedText: parsed.text } : {}),
|
|
85
|
+
};
|
|
86
|
+
await send("Input.dispatchKeyEvent", down);
|
|
87
|
+
await send("Input.dispatchKeyEvent", {
|
|
88
|
+
type: "keyUp",
|
|
89
|
+
key: parsed.key,
|
|
90
|
+
code: parsed.code,
|
|
91
|
+
modifiers: parsed.modifiers,
|
|
92
|
+
windowsVirtualKeyCode: parsed.virtualKeyCode,
|
|
93
|
+
nativeVirtualKeyCode: parsed.virtualKeyCode,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseShortcut(value) {
|
|
98
|
+
const parts = String(value || "Enter").split("+").map((part) => part.trim()).filter(Boolean);
|
|
99
|
+
const keyName = parts.pop() || "Enter";
|
|
100
|
+
let modifiers = 0;
|
|
101
|
+
for (const raw of parts) {
|
|
102
|
+
const name = raw === "Ctrl" ? "Control" : raw === "Cmd" || raw === "Command" ? "Meta" : raw;
|
|
103
|
+
if (!(name in MODIFIERS)) throw new Error(`unsupported key modifier: ${raw}`);
|
|
104
|
+
modifiers |= MODIFIERS[name];
|
|
105
|
+
}
|
|
106
|
+
const known = KEY_DATA[keyName];
|
|
107
|
+
if (known) return {
|
|
108
|
+
key: known.key || keyName,
|
|
109
|
+
code: known.code,
|
|
110
|
+
virtualKeyCode: known.virtualKeyCode,
|
|
111
|
+
modifiers,
|
|
112
|
+
text: "",
|
|
113
|
+
};
|
|
114
|
+
if ([...keyName].length !== 1) throw new Error(`unsupported key: ${keyName}`);
|
|
115
|
+
const character = keyName;
|
|
116
|
+
const upper = character.toUpperCase();
|
|
117
|
+
return {
|
|
118
|
+
key: character,
|
|
119
|
+
code: /[A-Za-z]/.test(character) ? `Key${upper}` : "",
|
|
120
|
+
virtualKeyCode: upper.codePointAt(0),
|
|
121
|
+
modifiers,
|
|
122
|
+
text: modifiers & (MODIFIERS.Control | MODIFIERS.Meta | MODIFIERS.Alt) ? "" : character,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizePoint(point) {
|
|
127
|
+
const x = Number(point?.x);
|
|
128
|
+
const y = Number(point?.y);
|
|
129
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) throw new Error("trusted input target has no usable viewport point");
|
|
130
|
+
return { x, y };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function trustedInputError(error, { safeToFallback, dispatchStarted }) {
|
|
134
|
+
const wrapped = new Error(`trusted browser input unavailable: ${cleanError(error)}`);
|
|
135
|
+
Object.defineProperties(wrapped, {
|
|
136
|
+
machineBridgeTrustedInput: { value: true },
|
|
137
|
+
safeToFallback: { value: safeToFallback === true },
|
|
138
|
+
dispatchStarted: { value: dispatchStarted === true },
|
|
139
|
+
});
|
|
140
|
+
return wrapped;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function cleanError(error) {
|
|
144
|
+
return String(error?.message || error || "unknown error").replace(/\s+/g, " ").slice(0, 500);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Object.defineProperty(globalThis, "__machineBridgeDevtoolsInput", {
|
|
148
|
+
value: Object.freeze({ perform }),
|
|
149
|
+
configurable: false,
|
|
150
|
+
});
|
|
151
|
+
})();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
8
|
+
"debugger",
|
|
8
9
|
"scripting",
|
|
9
10
|
"storage",
|
|
10
11
|
"alarms"
|
|
@@ -29,5 +30,5 @@
|
|
|
29
30
|
"action": {
|
|
30
31
|
"default_title": "Machine Bridge Browser"
|
|
31
32
|
},
|
|
32
|
-
"version_name": "0.
|
|
33
|
+
"version_name": "0.15.0"
|
|
33
34
|
}
|