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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-07-13
4
+
5
+ ### Daily-profile browser verification and protocol safety
6
+
7
+ - Verify the unpacked extension in the user's ordinary Chrome profile with a localhost-only live smoke test covering the acknowledged protocol handshake, real tab lifecycle, semantic inspection, open Shadow DOM, complex form fill, fixed DevTools text/click input, waits, screenshot capture, and cleanup. `browser status` and loopback health now report extension version/protocol, that Machine Bridge did not launch the browser, and the fact that daily-vs-isolated profile identity is not machine-verifiable.
8
+ - Upgrade the extension protocol to an acknowledged `hello`/`hello_ack` state machine with exact packaged-version, protocol, and capability equality. WebSocket open is no longer treated as authenticated readiness, pairing material is persisted only after a successful capability handshake, failed candidate pairing preserves the prior configuration, pairing-page and broker ports must match, and extension WebSocket origins require a canonical 32-character Chromium extension ID.
9
+ - Prevent duplicate side effects after an ambiguous trusted-input failure. `auto` falls back to DOM only before any DevTools `Input` command starts; after dispatch begins, the operation fails with an explicit unknown-outcome instruction to inspect the page before retrying. Screenshot capture temporarily activates only the requested tab, never focuses its window, and restores the previous active tab unless the user changed it concurrently.
10
+
11
+ ### Bounded page processing and extension architecture
12
+
13
+ - Apply `max_bytes` and `max_elements` as aggregate request budgets across at most 64 accessible frames. Replace full `outerHTML` construction with a bounded iterative DOM serializer, cap page scans at 100,000 nodes, bound page-controlled metadata/text, cap reusable refs at 10,000 per frame, redact URL userinfo, mark contenteditable secret controls as sensitive, and report frame/node/text/ref truncation explicitly.
14
+ - Report partial multi-field form mutation precisely when a later field or submission fails. Navigation waits now inherit the bounded request deadline rather than using a hidden fixed 30-second timer.
15
+ - Extract tab/page/wait/source/screenshot orchestration into fixed `browser-operations.js`; the Manifest V3 service worker now owns only pairing, transport, acknowledged readiness, cancellation, and response routing. Architecture tests enforce this responsibility boundary.
16
+
17
+ ### Cross-platform audit and regression coverage
18
+
19
+ - Replace ad-hoc Windows Scheduled Task argument quoting with the Windows CRT-compatible backslash/quote algorithm, including drive-root and trailing-backslash paths.
20
+ - Add behavior tests for handshake readiness, provisional pairing persistence, trusted-input replay prevention, focus restoration, aggregate frame/source budgets, hostile DOM/text bounds, partial form failure, strict extension origins, and Windows command-line quoting. Synchronize architecture, security, operations, testing, audit, and tool documentation.
21
+
3
22
  ## 0.14.0 - 2026-07-13
4
23
 
5
24
  ### Windows installation and OAuth callback reliability
package/README.md CHANGED
@@ -185,7 +185,7 @@ Skill discovery follows Codex-style progressive disclosure. Default project root
185
185
  Under canonical `full`, Machine Bridge also exposes structured local automation:
186
186
 
187
187
  - installed application discovery/opening and macOS Accessibility inspection/actions;
188
- - a packaged Chromium extension that controls the user's existing daily browser profile, active tabs, login state, and windows;
188
+ - a packaged Chromium extension that controls the profile into which the user loads it, including that profile's tabs, login state, and windows; Machine Bridge does not launch a separate browser process;
189
189
  - current DOM source and frame/open-Shadow-DOM inspection, stable semantic element references, actionability waits, fixed trusted mouse/keyboard input, explicit browser waits, tab management, complex multi-field forms, resource-backed secret fields, resource-backed file uploads, and screenshots.
190
190
 
191
191
  One-time browser setup:
@@ -195,7 +195,7 @@ machine-mcp browser setup
195
195
  machine-mcp browser status
196
196
  ```
197
197
 
198
- Load the printed unpacked-extension directory once in Chrome, Edge, Brave, Vivaldi, or another compatible Chromium browser. After an upgrade that adds permissions, reload the unpacked extension and accept the browser prompt; trusted input uses the Chromium `debugger` permission only for fixed, short-lived DevTools Input commands. The extension badge reports connection state, and clicking it opens the saved local pairing page. The local pairing token remains in owner-only state and the loopback pairing page; it is not returned through MCP. For a mass-market release, distribute the same extension as a signed browser-store build rather than asking end users to enable Developer mode. See [Local application and browser automation](docs/LOCAL_AUTOMATION.md).
198
+ Load the printed unpacked-extension directory in the Chromium profile you actually use; Machine Bridge does not install it into Playwright or a separate automation profile. After every Machine Bridge upgrade, reload the unpacked extension and accept any new browser permission. `machine-mcp browser status` reports both the expected packaged extension build and the authenticated connected build/protocol, and states that Machine Bridge did not launch the browser. It cannot infer whether the extension was loaded into a daily or isolated profile; that is determined by where the user installed it. Trusted input uses the Chromium `debugger` permission only for fixed, short-lived DevTools Input commands. The badge shows `ON` only after the broker acknowledges the version/capability handshake. The local pairing token remains in owner-only state and non-cacheable loopback HTML; it is not returned through MCP. For a mass-market release, distribute the same extension as a signed browser-store build rather than asking end users to enable Developer mode. See [Local application and browser automation](docs/LOCAL_AUTOMATION.md).
199
199
 
200
200
  Machine Bridge can discover, refresh, rank, and load capabilities automatically. The ChatGPT/MCP host still owns tool selection and approval, so the server cannot force a host to expose or invoke a recommended skill, command, app, or browser operation. Check `server_info.observability.capability_routing` or `project_overview.capabilityRouting` to distinguish “the host never called the resolver” from “the resolver ran but found no relevant capability.”
201
201
 
@@ -347,13 +347,13 @@ The exact `tools/list` response reflects the active local policy. Definitions co
347
347
  - `pair_browser_extension`
348
348
  - `browser_list_tabs`
349
349
  - `browser_manage_tabs` — create, activate, or close tabs
350
- - `browser_get_source` — bounded current DOM HTML, including selected frames
351
- - `browser_inspect_page` — semantic controls, stable per-document refs, state, and geometry
350
+ - `browser_get_source` — bounded current DOM HTML with one aggregate byte budget across up to 64 accessible frames
351
+ - `browser_inspect_page` — semantic controls, bounded LRU refs, state, geometry, and explicit frame/node/ref truncation metadata
352
352
  - `browser_wait` — bounded URL/load/text/element-state waits
353
- - `browser_action` — actionability checks plus `auto`, `trusted`, or `dom` input mode
353
+ - `browser_action` — actionability checks plus `auto`, `trusted`, or `dom` input mode; ambiguous post-dispatch failures are never replayed through DOM
354
354
  - `browser_fill_form`
355
355
  - `browser_upload_files` — registered local resources to file inputs
356
- - `browser_screenshot`
356
+ - `browser_screenshot` — restores the prior active tab and does not focus another window
357
357
 
358
358
 
359
359
  ### Mutation
package/SECURITY.md CHANGED
@@ -73,11 +73,11 @@ The global `model_instructions_file` is an explicit user trust decision. It is r
73
73
 
74
74
  Canonical `full` exposes structured browser and desktop UI authority. The browser extension operates the user's existing Chromium profile, including active login state, and has broad host access plus Chromium `debugger` permission so it can inspect arbitrary pages, fill complex forms, and issue trusted input. An authorized client may therefore read page content, click controls, submit forms, upload registered files, and cause transactions with the user's browser identity. macOS Accessibility actions similarly operate applications with the local user's UI authority.
75
75
 
76
- The browser loopback broker validates loopback `Host`, requires a random owner-only bearer subprotocol, accepts extension sockets only with a `chrome-extension://` origin, and authenticates additional local runtimes separately. Initial extension pairing is trust-on-first-use; after pairing, a different localhost page cannot replace the stored broker unless the user clicks the extension action while the genuine pairing page is active. The pairing token is never returned by MCP or written to operational logs. These controls defend against casual cross-origin and DNS-rebinding access; they do not protect against malicious code already running as the same OS user or a compromised browser extension profile.
76
+ The browser loopback broker validates loopback `Host`, requires a random owner-only bearer subprotocol, accepts extension sockets only from a canonical 32-character Chromium extension ID, requires the pairing page and WebSocket endpoint to use the same loopback port, and authenticates additional local runtimes separately. Initial extension pairing is trust-on-first-use; after pairing, a different localhost page cannot replace the stored broker unless the user clicks the extension action while the genuine pairing page is active. The pairing token is never returned by MCP or written to operational logs. These controls defend against casual cross-origin and DNS-rebinding access; they do not protect against malicious code already running as the same OS user or a compromised browser extension profile.
77
77
 
78
- Caller-supplied JavaScript, AppleScript, JXA source, and arbitrary DevTools methods are deliberately unsupported. Actions use fixed implementation code and structured selectors. The debugger adapter is restricted to fixed `Input` commands, attaches only around one trusted action, and detaches in `finally`; `input_mode` makes fallback behavior explicit. The broker validates a versioned extension capability hello before activating a connection and does not let an invalid replacement candidate displace an already compatible extension. This removes an avoidable arbitrary-evaluation surface but does not make DOM or Accessibility actions safe. Pages can contain prompt injection, misleading labels, hidden consequences, cross-origin frames unavailable to inspection, or state that changes between inspection and submission. High-impact account, financial, legal, medical, publishing, deletion, and purchase actions require contextual review and any host/user confirmation the client provides.
78
+ Caller-supplied JavaScript, AppleScript, JXA source, and arbitrary DevTools methods are deliberately unsupported. Actions use fixed implementation code and structured selectors. The debugger adapter is restricted to fixed `Input` commands, attaches only around one trusted action, and detaches in `finally`. `auto` falls back only before any Input dispatch; after dispatch begins, failure is reported as an unknown outcome and is never replayed through DOM. The broker and extension require a protocol-3 `hello`/`hello_ack` exchange plus exact packaged-version and capability equality before either reports readiness. Pairing material is persisted only after acknowledgement, and an invalid replacement candidate cannot displace the current connection or overwrite its stored endpoint/token. This removes an avoidable arbitrary-evaluation surface but does not make DOM or Accessibility actions safe. Pages can contain prompt injection, misleading labels, hidden consequences, cross-origin frames unavailable to inspection, or state that changes between inspection and submission. High-impact account, financial, legal, medical, publishing, deletion, and purchase actions require contextual review and any host/user confirmation the client provides.
79
79
 
80
- Text and file resources can be injected locally so their contents do not appear in MCP arguments or results. The destination page/application still receives the value, and remote tool metadata/results still traverse the Worker and host. Page source and screenshots can themselves contain secrets. Browser/app tool arguments, source, screenshots, field values, and results are intentionally omitted from operational logs.
80
+ Text and file resources can be injected locally so their contents do not appear in MCP arguments or results. The destination page/application still receives the value, and remote tool metadata/results still traverse the Worker and host. Page source and screenshots can themselves contain secrets. Source/inspection budgets are aggregate across frames, page-controlled metadata is bounded, URL userinfo is removed from semantic snapshots, and contenteditable controls with secret-like identity are treated as sensitive; these limits reduce accidental exposure but do not classify every possible secret. Browser/app tool arguments, source, screenshots, field values, and results are intentionally omitted from operational logs.
81
81
 
82
82
  ## Filesystem exposure
83
83
 
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
220
+ const escapeAttribute = (value) => bounded(value, MAX_ATTRIBUTE_CHARS).replaceAll("&", "&amp;").replaceAll('"', "&quot;");
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
+ })();
@@ -39,13 +39,18 @@
39
39
  async function withDebugger(tabId, operation) {
40
40
  const target = { tabId };
41
41
  let attached = false;
42
+ let dispatchStarted = false;
42
43
  try {
43
44
  await chrome.debugger.attach(target, "1.3");
44
45
  attached = true;
45
- const send = (method, params = {}) => chrome.debugger.sendCommand(target, method, params);
46
+ const send = (method, params = {}) => {
47
+ dispatchStarted = true;
48
+ return chrome.debugger.sendCommand(target, method, params);
49
+ };
46
50
  return await operation(send);
47
51
  } catch (error) {
48
- throw new Error(`trusted browser input unavailable: ${cleanError(error)}`);
52
+ if (error?.machineBridgeTrustedInput === true) throw error;
53
+ throw trustedInputError(error, { safeToFallback: !dispatchStarted, dispatchStarted });
49
54
  } finally {
50
55
  if (attached) {
51
56
  try { await chrome.debugger.detach(target); } catch {}
@@ -125,6 +130,16 @@
125
130
  return { x, y };
126
131
  }
127
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
+
128
143
  function cleanError(error) {
129
144
  return String(error?.message || error || "unknown error").replace(/\s+/g, " ").slice(0, 500);
130
145
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "0.14.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",
@@ -30,5 +30,5 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "0.14.0"
33
+ "version_name": "0.15.0"
34
34
  }