machine-bridge-mcp 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +18 -8
- package/SECURITY.md +10 -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 +6 -4
- package/docs/AUDIT.md +24 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +13 -5
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +9 -2
- package/package.json +16 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -9
- package/src/local/browser-bridge.mjs +69 -143
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +77 -0
- package/src/local/cli.mjs +16 -507
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +8 -3
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +11 -11
- package/src/worker/index.ts +69 -524
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
importScripts("devtools-input.js");
|
|
1
|
+
importScripts("devtools-input.js", "browser-operations.js");
|
|
2
2
|
|
|
3
3
|
let socket = null;
|
|
4
4
|
let reconnectTimer = null;
|
|
5
5
|
let reconnectAttempt = 0;
|
|
6
6
|
let keepaliveTimer = null;
|
|
7
7
|
const MAX_RESULT_BYTES = 7 * 1024 * 1024;
|
|
8
|
+
const BROWSER_EXTENSION_PROTOCOL = 3;
|
|
9
|
+
const HANDSHAKE_TIMEOUT_MS = 3000;
|
|
8
10
|
const activeRequests = new Map();
|
|
9
11
|
|
|
10
12
|
chrome.runtime.onInstalled.addListener(() => { ensureReconnectAlarm(); void connectFromStorage(); });
|
|
@@ -22,7 +24,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
22
24
|
chrome.action.onClicked.addListener((tab) => void handleActionClick(tab));
|
|
23
25
|
|
|
24
26
|
async function handleActionClick(tab) {
|
|
25
|
-
if (tab?.id &&
|
|
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
|
@@ -58,9 +58,9 @@ See [Session instructions, skills, commands, and capability discovery](AGENT_CON
|
|
|
58
58
|
|
|
59
59
|
### Browser extension and machine broker
|
|
60
60
|
|
|
61
|
-
`BrowserBridgeManager` owns a loopback HTTP/WebSocket broker. The first runtime for the machine-level state root becomes broker owner; additional workspaces and stdio runtimes authenticate to `/runtime` and proxy through the same extension socket. This preserves one extension pairing while allowing multiple local MCP runtimes.
|
|
61
|
+
`BrowserBridgeManager` owns connection orchestration for a loopback HTTP/WebSocket broker. Extension version/capability parsing lives in `browser-extension-protocol.mjs`; pairing files and local HTML/Host/Origin helpers live in `browser-pairing-store.mjs`. The first runtime for the machine-level state root becomes broker owner; additional workspaces and stdio runtimes authenticate to `/runtime` and proxy through the same extension socket. This preserves one extension pairing while allowing multiple local MCP runtimes.
|
|
62
62
|
|
|
63
|
-
The packaged Manifest V3 extension runs in the user's existing Chromium profile. Its service worker
|
|
63
|
+
The packaged Manifest V3 extension runs in the user's existing Chromium profile. Its service worker is limited to pairing, transport, acknowledged protocol readiness, cancellation, and response routing. Fixed `browser-operations.js` owns tab lifecycle, aggregate frame/source budgets, waits, screenshots, and input-backend selection; fixed `page-automation.js` is injected into selected frames for snapshot-version-2 semantics, stable refs, bounded DOM/text traversal, actionability checks, open-Shadow-DOM traversal, structured DOM operations, multi-field forms, and resource-backed file inputs. Fixed `devtools-input.js` exposes only bounded mouse, keyboard, and text sequences through the Chromium debugger API; callers cannot select CDP methods. Trusted sessions attach for one action and detach in `finally`; DOM fallback is allowed only before any DevTools Input dispatch, preventing duplicate side effects after an ambiguous command failure. Protocol 3 requires bidirectional `hello`/`hello_ack` plus exact packaged-version and capability equality; pairing state and replacement are committed only after validation, so an invalid candidate cannot displace or overwrite the working configuration. The broker validates loopback hostnames, canonical extension IDs, matching pairing/broker ports, bearer subprotocols, message sizes, concurrency, and deadlines. Pairing material is owner-only and omitted from MCP/log output.
|
|
64
64
|
|
|
65
65
|
See [Local application and browser automation](LOCAL_AUTOMATION.md).
|
|
66
66
|
|
|
@@ -86,7 +86,9 @@ Local resource registrations remain in owner-only state and are reloaded for eve
|
|
|
86
86
|
|
|
87
87
|
### Canonical policy contracts
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
Policy revision 4 is loaded from one shared JSON contract by both local and Worker policy evaluators. Tool advertisement and execution authorization use catalog availability classes; managers receive the same authorizer instead of reimplementing profile conditionals. Read-only job/resource inspection is `always`, job cancellation is write-gated, and starting a persistent job requires `write+direct-exec`.
|
|
90
|
+
|
|
91
|
+
Named profiles are normalized to their complete capability sets. In particular, `full` always means write enabled, shell execution, unrestricted paths, complete parent environment, absolute-path display, and every catalog tool. CLI flags that alter an individual capability deliberately change the profile identity to `custom`. Policy revision 4 repairs stale or manually edited named-profile fields rather than allowing a misleading partially restricted `full` label, and applies compound availability requirements from the shared contract.
|
|
90
92
|
|
|
91
93
|
The full-only `generate_ssh_key_resource` operation is implemented locally. The Worker only filters and relays its shared catalog definition. Local generation uses `ssh-keygen`, verifies public/private correspondence, registers the private file through the same owner-only state transaction as the CLI, and rolls back a newly created pair if state persistence fails.
|
|
92
94
|
|
|
@@ -111,7 +113,7 @@ The daemon attachment deliberately omits workspace path/name/hash and process ID
|
|
|
111
113
|
|
|
112
114
|
### Autostart layer
|
|
113
115
|
|
|
114
|
-
The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions. Credentials are not embedded in service definitions; the daemon loads owner-only state. The exact policy is stored in owner-only state. launchd/systemd definitions contain the workspace/state-root selectors,
|
|
116
|
+
The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions. Credentials are not embedded in service definitions; the daemon loads owner-only state. The exact policy is stored in owner-only state. launchd/systemd definitions contain the workspace/state-root selectors, `warn` plus JSON log settings, and a sanitized absolute-only PATH captured at installation so background `full` mode can resolve the same developer tools without accepting relative PATH entries.
|
|
115
117
|
|
|
116
118
|
The platform adapters normalize launchd, systemd, and Windows Scheduled Task operations to one `{ok, provider}` result contract. Removal is not a provider-specific sequence: `service-lifecycle.mjs` first stops the provider, then every verified workspace daemon in scope, and only then removes the definition. A failed stop or unverifiable process prevents definition/state deletion.
|
|
117
119
|
|