machine-bridge-mcp 0.12.2 → 0.14.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 +44 -13
- package/SECURITY.md +7 -3
- package/browser-extension/devtools-input.js +136 -0
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/page-automation.js +309 -29
- package/browser-extension/service-worker.js +108 -5
- package/docs/AGENT_CONTEXT.md +16 -6
- package/docs/ARCHITECTURE.md +7 -5
- package/docs/AUDIT.md +13 -1
- package/docs/CLIENTS.md +1 -1
- package/docs/ENGINEERING.md +1 -0
- package/docs/LOCAL_AUTOMATION.md +14 -10
- package/docs/LOGGING.md +3 -1
- package/docs/OPERATIONS.md +17 -5
- package/docs/TESTING.md +8 -7
- package/package.json +10 -4
- package/src/local/agent-context.mjs +76 -19
- package/src/local/app-automation.mjs +15 -1
- package/src/local/browser-bridge.mjs +186 -88
- package/src/local/browser-command.mjs +126 -0
- package/src/local/capability-observer.mjs +56 -0
- package/src/local/cli.mjs +21 -3
- package/src/local/default-instructions.mjs +21 -91
- package/src/local/network-proxy.mjs +34 -0
- package/src/local/process-sessions.mjs +7 -0
- package/src/local/project-package.mjs +234 -0
- package/src/local/relay-connection.mjs +26 -0
- package/src/local/runtime.mjs +23 -20
- package/src/shared/tool-catalog.json +212 -6
- package/src/worker/index.ts +14 -8
|
@@ -9,9 +9,10 @@ const MAX_UI_ELEMENTS = 500;
|
|
|
9
9
|
const MAX_UI_DEPTH = 12;
|
|
10
10
|
const MAX_TEXT_CHARS = 4000;
|
|
11
11
|
const APP_NAME_PATTERN = /^[^\0\r\n]{1,300}$/;
|
|
12
|
+
const DEFAULT_APPLICATION_CACHE_MS = 30_000;
|
|
12
13
|
|
|
13
14
|
export class AppAutomationManager {
|
|
14
|
-
constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null }) {
|
|
15
|
+
constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () => Date.now() }) {
|
|
15
16
|
this.policy = policy || {};
|
|
16
17
|
this.displayPath = displayPath;
|
|
17
18
|
this.runProcess = runProcess;
|
|
@@ -20,6 +21,9 @@ export class AppAutomationManager {
|
|
|
20
21
|
this.platform = platform;
|
|
21
22
|
this.home = home;
|
|
22
23
|
this.applicationRoots = applicationRoots;
|
|
24
|
+
this.applicationCacheMs = Math.max(0, Number(applicationCacheMs) || 0);
|
|
25
|
+
this.now = now;
|
|
26
|
+
this.applicationCache = null;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
capabilities() {
|
|
@@ -57,6 +61,16 @@ export class AppAutomationManager {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
async discoverApplications(context = {}) {
|
|
64
|
+
const now = this.now();
|
|
65
|
+
if (this.applicationCache && now - this.applicationCache.createdAt < this.applicationCacheMs) {
|
|
66
|
+
return this.applicationCache.result;
|
|
67
|
+
}
|
|
68
|
+
const result = await this.scanApplications(context);
|
|
69
|
+
this.applicationCache = { createdAt: this.now(), result };
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async scanApplications(context = {}) {
|
|
60
74
|
if (this.platform === "darwin") {
|
|
61
75
|
return listMacApplications(this.applicationRoots || ["/Applications", "/System/Applications", join(this.home, "Applications")], context, this.throwIfCancelled);
|
|
62
76
|
}
|
|
@@ -7,6 +7,11 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
7
7
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
8
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
9
|
import { assertStateMaintenanceAvailable, ownerOnlyFile, packageRoot } from "./state.mjs";
|
|
10
|
+
import {
|
|
11
|
+
clampInt, normalizeBrowserAction, normalizeBrowserSelector, normalizeBrowserWait, normalizeFormAction,
|
|
12
|
+
normalizeInputMode, normalizeNavigationWait, normalizeTabCommand, optionalInteger, optionalString,
|
|
13
|
+
validateNavigationUrl,
|
|
14
|
+
} from "./browser-command.mjs";
|
|
10
15
|
|
|
11
16
|
const DEFAULT_PORT = 39393;
|
|
12
17
|
const MAX_PORT_ATTEMPTS = 10;
|
|
@@ -18,6 +23,11 @@ const MAX_FIELD_VALUE_CHARS = 128 * 1024;
|
|
|
18
23
|
const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
|
|
19
24
|
const PAIRING_FILE = "browser-bridge.json";
|
|
20
25
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
26
|
+
const BROWSER_EXTENSION_PROTOCOL = 2;
|
|
27
|
+
const EXTENSION_HANDSHAKE_MS = 3_000;
|
|
28
|
+
const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
|
|
29
|
+
"semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
|
|
30
|
+
]);
|
|
21
31
|
|
|
22
32
|
export class BrowserBridgeManager {
|
|
23
33
|
constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
|
|
@@ -30,10 +40,14 @@ export class BrowserBridgeManager {
|
|
|
30
40
|
this.server = null;
|
|
31
41
|
this.wss = null;
|
|
32
42
|
this.socket = null;
|
|
43
|
+
this.pendingExtensionSocket = null;
|
|
33
44
|
this.upstream = null;
|
|
34
45
|
this.runtimeClients = new Set();
|
|
35
46
|
this.proxyRoutes = new Map();
|
|
36
47
|
this.proxyExtensionConnected = false;
|
|
48
|
+
this.extensionInfo = null;
|
|
49
|
+
this.proxyExtensionInfo = null;
|
|
50
|
+
this.proxyExtensionReloadRequired = false;
|
|
37
51
|
this.recoveryTimer = null;
|
|
38
52
|
this.stopping = false;
|
|
39
53
|
this.pending = new Map();
|
|
@@ -47,16 +61,26 @@ export class BrowserBridgeManager {
|
|
|
47
61
|
await this.ensureStarted(context);
|
|
48
62
|
return {
|
|
49
63
|
available: true,
|
|
50
|
-
connected: this.
|
|
64
|
+
connected: this.extensionConnected(),
|
|
51
65
|
broker_role: this.server ? "owner" : this.upstream?.readyState === 1 ? "client" : "unavailable",
|
|
52
66
|
endpoint: `ws://127.0.0.1:${this.port}/extension`,
|
|
53
67
|
pairing_url: `http://127.0.0.1:${this.port}/pair`,
|
|
54
68
|
extension_path: this.extensionPath,
|
|
69
|
+
extension_protocol: this.extensionStatusInfo()?.protocol || null,
|
|
70
|
+
extension_version: this.extensionStatusInfo()?.version || "",
|
|
71
|
+
extension_capabilities: this.extensionStatusInfo()?.capabilities || [],
|
|
72
|
+
extension_reload_required: this.extensionReloadRequired(),
|
|
55
73
|
supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
|
|
56
74
|
controls_existing_profile: true,
|
|
57
75
|
uses_existing_tabs_and_login_state: true,
|
|
58
76
|
source_access: true,
|
|
77
|
+
semantic_snapshot_refs: true,
|
|
78
|
+
actionability_waits: true,
|
|
79
|
+
trusted_input: true,
|
|
80
|
+
input_modes: ["auto", "trusted", "dom"],
|
|
59
81
|
complex_form_fill: true,
|
|
82
|
+
tab_management: true,
|
|
83
|
+
explicit_waits: true,
|
|
60
84
|
screenshots: true,
|
|
61
85
|
restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
|
|
62
86
|
security: {
|
|
@@ -86,6 +110,7 @@ export class BrowserBridgeManager {
|
|
|
86
110
|
"Open the browser extensions page and enable developer mode.",
|
|
87
111
|
"Load the unpacked extension from extension_path once.",
|
|
88
112
|
"Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
|
|
113
|
+
"After upgrades, reload the unpacked extension and accept any newly requested browser permission.",
|
|
89
114
|
],
|
|
90
115
|
};
|
|
91
116
|
}
|
|
@@ -98,6 +123,16 @@ export class BrowserBridgeManager {
|
|
|
98
123
|
return response;
|
|
99
124
|
}
|
|
100
125
|
|
|
126
|
+
async manageTabs(args = {}, context = {}) {
|
|
127
|
+
return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async wait(args = {}, context = {}) {
|
|
131
|
+
const params = normalizeBrowserWait(args);
|
|
132
|
+
const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
|
|
133
|
+
return this.request("wait", params, conditionTimeoutSeconds + 5, context);
|
|
134
|
+
}
|
|
135
|
+
|
|
101
136
|
async getSource(args = {}, context = {}) {
|
|
102
137
|
const response = await this.request("get_source", {
|
|
103
138
|
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
@@ -130,17 +165,24 @@ export class BrowserBridgeManager {
|
|
|
130
165
|
url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
|
|
131
166
|
value: null,
|
|
132
167
|
key: optionalString(args.key, "key", 100),
|
|
133
|
-
waitFor:
|
|
168
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
169
|
+
inputMode: normalizeInputMode(args.input_mode),
|
|
170
|
+
elementTimeoutMs: clampInt(args.element_timeout_seconds, 10, 1, 60) * 1000,
|
|
134
171
|
};
|
|
135
172
|
if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
|
|
136
173
|
if (action !== "press" && payload.key) throw new Error("key is only valid for press");
|
|
174
|
+
if (payload.inputMode === "trusted" && !["click", "double_click", "hover", "press", "type_text"].includes(action)) {
|
|
175
|
+
throw new Error("input_mode=trusted supports click, double_click, hover, press, and type_text only");
|
|
176
|
+
}
|
|
137
177
|
if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
|
|
138
178
|
if (args.value_resource !== undefined) {
|
|
139
179
|
if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
|
|
140
180
|
payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
|
|
141
181
|
}
|
|
142
|
-
if (payload.value !== null && !["fill", "select", "press"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
|
|
143
|
-
const
|
|
182
|
+
if (payload.value !== null && !["fill", "select", "press", "type_text"].includes(action)) throw new Error(`value is not valid for browser action '${action}'`);
|
|
183
|
+
const pageAction = !["navigate", "reload", "back", "forward"].includes(action);
|
|
184
|
+
const defaultTimeout = pageAction ? Math.min(120, Math.max(30, payload.elementTimeoutMs / 1000 + 5)) : 30;
|
|
185
|
+
const response = await this.request("action", payload, clampInt(args.timeout_seconds, defaultTimeout, 1, 120), context);
|
|
144
186
|
return {
|
|
145
187
|
...response,
|
|
146
188
|
value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
|
|
@@ -170,6 +212,7 @@ export class BrowserBridgeManager {
|
|
|
170
212
|
}
|
|
171
213
|
const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
|
|
172
214
|
if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
|
|
215
|
+
if (value !== null && !["fill", "select"].includes(action)) throw new Error(`fields[${index}] value is not valid for action '${action}'`);
|
|
173
216
|
fields.push({
|
|
174
217
|
selector: normalizeBrowserSelector(input.selector, action),
|
|
175
218
|
value,
|
|
@@ -177,14 +220,16 @@ export class BrowserBridgeManager {
|
|
|
177
220
|
sensitive: input.sensitive === true || input.value_resource !== undefined,
|
|
178
221
|
});
|
|
179
222
|
}
|
|
223
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
180
224
|
return this.request("fill_form", {
|
|
181
225
|
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
182
226
|
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
183
227
|
fields,
|
|
184
228
|
submit: args.submit === true,
|
|
185
229
|
submitSelector: args.submit_selector ? normalizeBrowserSelector(args.submit_selector, "click") : null,
|
|
186
|
-
waitFor:
|
|
187
|
-
|
|
230
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
231
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
232
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
188
233
|
}
|
|
189
234
|
|
|
190
235
|
async uploadFiles(args = {}, context = {}) {
|
|
@@ -208,12 +253,14 @@ export class BrowserBridgeManager {
|
|
|
208
253
|
data: resource.buffer.toString("base64"),
|
|
209
254
|
});
|
|
210
255
|
}
|
|
256
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
211
257
|
const result = await this.request("upload_files", {
|
|
212
258
|
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
213
259
|
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
214
260
|
selector: normalizeBrowserSelector(args.selector, "fill"),
|
|
215
261
|
files,
|
|
216
|
-
|
|
262
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
263
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
217
264
|
return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
|
|
218
265
|
}
|
|
219
266
|
|
|
@@ -244,7 +291,7 @@ export class BrowserBridgeManager {
|
|
|
244
291
|
await this.ensureStarted(context);
|
|
245
292
|
this.throwIfCancelled(context);
|
|
246
293
|
const transport = this.server ? this.socket : this.upstream;
|
|
247
|
-
const extensionConnected = this.
|
|
294
|
+
const extensionConnected = this.extensionConnected();
|
|
248
295
|
if (!transport || transport.readyState !== 1 || !extensionConnected) {
|
|
249
296
|
throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
|
|
250
297
|
}
|
|
@@ -368,12 +415,15 @@ export class BrowserBridgeManager {
|
|
|
368
415
|
}
|
|
369
416
|
const message = parsed.message;
|
|
370
417
|
if (!settled) {
|
|
371
|
-
if (message.type !== "hello" || message.role !== "runtime") {
|
|
418
|
+
if (message.type !== "hello" || message.role !== "runtime" || message.protocol !== 1) {
|
|
372
419
|
closeProtocolSocket(ws, 1002, "runtime hello required");
|
|
373
420
|
return;
|
|
374
421
|
}
|
|
375
422
|
this.upstream = ws;
|
|
376
|
-
|
|
423
|
+
const claimedExtension = message.extension_connected === true;
|
|
424
|
+
this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
|
|
425
|
+
this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
|
|
426
|
+
this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
|
|
377
427
|
finish(true);
|
|
378
428
|
return;
|
|
379
429
|
}
|
|
@@ -384,6 +434,8 @@ export class BrowserBridgeManager {
|
|
|
384
434
|
if (this.upstream === ws) {
|
|
385
435
|
this.upstream = null;
|
|
386
436
|
this.proxyExtensionConnected = false;
|
|
437
|
+
this.proxyExtensionInfo = null;
|
|
438
|
+
this.proxyExtensionReloadRequired = false;
|
|
387
439
|
this.rejectPending("browser broker disconnected");
|
|
388
440
|
if (settled) this.scheduleBrokerRecovery();
|
|
389
441
|
}
|
|
@@ -406,36 +458,79 @@ export class BrowserBridgeManager {
|
|
|
406
458
|
}
|
|
407
459
|
});
|
|
408
460
|
ws.on("error", () => {});
|
|
409
|
-
safeSocketSend(ws, {
|
|
461
|
+
safeSocketSend(ws, {
|
|
462
|
+
type: "hello", role: "runtime", protocol: 1,
|
|
463
|
+
extension_connected: this.extensionConnected(),
|
|
464
|
+
extension_info: this.extensionStatusInfo(),
|
|
465
|
+
extension_reload_required: this.extensionReloadRequired(),
|
|
466
|
+
});
|
|
410
467
|
return;
|
|
411
468
|
}
|
|
412
|
-
if (this.
|
|
413
|
-
|
|
469
|
+
if (this.pendingExtensionSocket && this.pendingExtensionSocket.readyState === 1) {
|
|
470
|
+
this.pendingExtensionSocket.close(4001, "superseded by a newer extension candidate");
|
|
471
|
+
}
|
|
472
|
+
this.pendingExtensionSocket = ws;
|
|
473
|
+
this.broadcastRuntimeStatus(this.extensionConnected());
|
|
474
|
+
ws.extensionReady = false;
|
|
475
|
+
ws.handshakeTimer = setTimeout(() => closeProtocolSocket(ws, 1002, "extension hello required; reload the extension"), EXTENSION_HANDSHAKE_MS);
|
|
476
|
+
ws.handshakeTimer.unref?.();
|
|
414
477
|
ws.on("message", (data) => this.handleExtensionMessage(ws, data));
|
|
415
478
|
ws.on("close", () => {
|
|
479
|
+
clearTimeout(ws.handshakeTimer);
|
|
480
|
+
if (this.pendingExtensionSocket === ws) {
|
|
481
|
+
this.pendingExtensionSocket = null;
|
|
482
|
+
this.broadcastRuntimeStatus(this.extensionConnected());
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
416
485
|
if (this.socket !== ws) return;
|
|
417
486
|
this.socket = null;
|
|
487
|
+
this.extensionInfo = null;
|
|
418
488
|
this.rejectPending("browser extension disconnected");
|
|
419
|
-
|
|
420
|
-
clearTimeout(route.timeout);
|
|
421
|
-
try { route.socket.send(JSON.stringify({ type: "response", id: route.id, ok: false, error: "browser extension disconnected" })); } catch {}
|
|
422
|
-
this.proxyRoutes.delete(id);
|
|
423
|
-
}
|
|
489
|
+
this.rejectProxyRoutes("browser extension disconnected");
|
|
424
490
|
this.broadcastRuntimeStatus(false);
|
|
425
491
|
});
|
|
426
492
|
ws.on("error", () => {});
|
|
427
|
-
safeSocketSend(ws, { type: "hello", role: "extension", protocol:
|
|
428
|
-
this.broadcastRuntimeStatus(true);
|
|
493
|
+
safeSocketSend(ws, { type: "hello", role: "extension", protocol: BROWSER_EXTENSION_PROTOCOL });
|
|
429
494
|
}
|
|
430
495
|
|
|
431
496
|
handleExtensionMessage(socket, data) {
|
|
432
|
-
if (this.socket !== socket) return;
|
|
497
|
+
if (this.socket !== socket && this.pendingExtensionSocket !== socket) return;
|
|
433
498
|
const parsed = parseBrowserSocketMessage(data);
|
|
434
499
|
if (!parsed.ok) {
|
|
435
500
|
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
436
501
|
return;
|
|
437
502
|
}
|
|
438
503
|
const message = parsed.message;
|
|
504
|
+
if (message.type === "hello") {
|
|
505
|
+
if (socket.extensionReady) {
|
|
506
|
+
closeProtocolSocket(socket, 1002, "duplicate extension hello");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
let info;
|
|
510
|
+
try { info = parseExtensionHello(message); }
|
|
511
|
+
catch (error) {
|
|
512
|
+
closeProtocolSocket(socket, 1002, String(error?.message || error).slice(0, 120));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
clearTimeout(socket.handshakeTimer);
|
|
516
|
+
socket.extensionReady = true;
|
|
517
|
+
if (this.pendingExtensionSocket === socket) this.pendingExtensionSocket = null;
|
|
518
|
+
const previous = this.socket;
|
|
519
|
+
this.socket = socket;
|
|
520
|
+
this.extensionInfo = info;
|
|
521
|
+
if (previous && previous !== socket) {
|
|
522
|
+
this.rejectPending("browser extension was replaced; retry the browser request");
|
|
523
|
+
this.rejectProxyRoutes("browser extension was replaced; retry the browser request");
|
|
524
|
+
if (previous.readyState === 1) previous.close(4001, "superseded");
|
|
525
|
+
}
|
|
526
|
+
this.broadcastRuntimeStatus(true);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!socket.extensionReady) {
|
|
530
|
+
closeProtocolSocket(socket, 1002, "extension hello required; reload the extension");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (message.type === "ping") return;
|
|
439
534
|
if (message.type !== "response" || typeof message.id !== "string") {
|
|
440
535
|
closeProtocolSocket(socket, 1002, "invalid extension protocol message");
|
|
441
536
|
return;
|
|
@@ -476,7 +571,7 @@ export class BrowserBridgeManager {
|
|
|
476
571
|
closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
|
|
477
572
|
return;
|
|
478
573
|
}
|
|
479
|
-
if (!this.
|
|
574
|
+
if (!this.extensionConnected()) {
|
|
480
575
|
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
|
|
481
576
|
return;
|
|
482
577
|
}
|
|
@@ -511,7 +606,10 @@ export class BrowserBridgeManager {
|
|
|
511
606
|
|
|
512
607
|
handleUpstreamMessage(message) {
|
|
513
608
|
if (message.type === "status" && typeof message.extension_connected === "boolean") {
|
|
514
|
-
|
|
609
|
+
const claimedExtension = message.extension_connected === true;
|
|
610
|
+
this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
|
|
611
|
+
this.proxyExtensionConnected = claimedExtension && Boolean(this.proxyExtensionInfo);
|
|
612
|
+
this.proxyExtensionReloadRequired = message.extension_reload_required === true || (claimedExtension && !this.proxyExtensionInfo);
|
|
515
613
|
if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
|
|
516
614
|
return true;
|
|
517
615
|
}
|
|
@@ -526,7 +624,11 @@ export class BrowserBridgeManager {
|
|
|
526
624
|
}
|
|
527
625
|
|
|
528
626
|
broadcastRuntimeStatus(connected) {
|
|
529
|
-
const message = {
|
|
627
|
+
const message = {
|
|
628
|
+
type: "status", extension_connected: connected,
|
|
629
|
+
extension_info: connected ? this.extensionStatusInfo() : null,
|
|
630
|
+
extension_reload_required: this.extensionReloadRequired(),
|
|
631
|
+
};
|
|
530
632
|
for (const client of this.runtimeClients) safeSocketSend(client, message);
|
|
531
633
|
}
|
|
532
634
|
|
|
@@ -552,7 +654,7 @@ export class BrowserBridgeManager {
|
|
|
552
654
|
return;
|
|
553
655
|
}
|
|
554
656
|
if (url.pathname === "/healthz") {
|
|
555
|
-
sendJson(response, { ok: true, connected: this.
|
|
657
|
+
sendJson(response, { ok: true, connected: this.extensionConnected(), broker: "machine-bridge-browser" });
|
|
556
658
|
return;
|
|
557
659
|
}
|
|
558
660
|
if (url.pathname === "/pair") {
|
|
@@ -585,6 +687,14 @@ export class BrowserBridgeManager {
|
|
|
585
687
|
this.pending.clear();
|
|
586
688
|
}
|
|
587
689
|
|
|
690
|
+
rejectProxyRoutes(message) {
|
|
691
|
+
for (const [id, route] of this.proxyRoutes) {
|
|
692
|
+
clearTimeout(route.timeout);
|
|
693
|
+
safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
|
|
694
|
+
this.proxyRoutes.delete(id);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
588
698
|
stop() {
|
|
589
699
|
this.stopping = true;
|
|
590
700
|
clearTimeout(this.recoveryTimer);
|
|
@@ -592,9 +702,15 @@ export class BrowserBridgeManager {
|
|
|
592
702
|
this.rejectPending("browser bridge stopped");
|
|
593
703
|
try { this.upstream?.close(1001, "runtime stopped"); } catch {}
|
|
594
704
|
try { this.socket?.close(1001, "runtime stopped"); } catch {}
|
|
705
|
+
try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
|
|
595
706
|
for (const client of this.runtimeClients) try { client.close(1001, "runtime stopped"); } catch {}
|
|
596
707
|
this.upstream = null;
|
|
597
708
|
this.socket = null;
|
|
709
|
+
this.pendingExtensionSocket = null;
|
|
710
|
+
this.extensionInfo = null;
|
|
711
|
+
this.proxyExtensionConnected = false;
|
|
712
|
+
this.proxyExtensionInfo = null;
|
|
713
|
+
this.proxyExtensionReloadRequired = false;
|
|
598
714
|
this.runtimeClients.clear();
|
|
599
715
|
for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
|
|
600
716
|
this.proxyRoutes.clear();
|
|
@@ -604,6 +720,22 @@ export class BrowserBridgeManager {
|
|
|
604
720
|
this.server = null;
|
|
605
721
|
}
|
|
606
722
|
|
|
723
|
+
extensionConnected() {
|
|
724
|
+
return this.server
|
|
725
|
+
? this.socket?.readyState === 1 && Boolean(this.extensionInfo)
|
|
726
|
+
: this.upstream?.readyState === 1 && this.proxyExtensionConnected && Boolean(this.proxyExtensionInfo);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
extensionStatusInfo() {
|
|
730
|
+
return this.server ? this.extensionInfo : this.proxyExtensionInfo;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
extensionReloadRequired() {
|
|
734
|
+
return this.server
|
|
735
|
+
? this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected()
|
|
736
|
+
: this.proxyExtensionReloadRequired;
|
|
737
|
+
}
|
|
738
|
+
|
|
607
739
|
assertFull(tool) {
|
|
608
740
|
if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
|
|
609
741
|
throw new Error(`${tool} requires the canonical full profile`);
|
|
@@ -611,6 +743,35 @@ export class BrowserBridgeManager {
|
|
|
611
743
|
}
|
|
612
744
|
}
|
|
613
745
|
|
|
746
|
+
function normalizeCompatibleExtensionInfo(value) {
|
|
747
|
+
const info = normalizeExtensionInfo(value);
|
|
748
|
+
if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL) return null;
|
|
749
|
+
if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
|
|
750
|
+
return info;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function parseExtensionHello(message) {
|
|
754
|
+
if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
|
|
755
|
+
throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
|
|
756
|
+
}
|
|
757
|
+
const info = normalizeExtensionInfo(message);
|
|
758
|
+
if (!info) throw new Error("invalid extension hello; reload the extension");
|
|
759
|
+
const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
|
|
760
|
+
if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
|
|
761
|
+
return info;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function normalizeExtensionInfo(value) {
|
|
765
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
766
|
+
const protocol = Number(value.protocol);
|
|
767
|
+
const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
|
|
768
|
+
const capabilities = Array.isArray(value.capabilities)
|
|
769
|
+
? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
|
|
770
|
+
: [];
|
|
771
|
+
if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
|
|
772
|
+
return { protocol, version, capabilities };
|
|
773
|
+
}
|
|
774
|
+
|
|
614
775
|
async function loadOrCreatePairing(stateRoot) {
|
|
615
776
|
if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
|
|
616
777
|
assertStateMaintenanceAvailable(stateRoot);
|
|
@@ -697,42 +858,6 @@ function sendJson(response, value) {
|
|
|
697
858
|
response.end(`${JSON.stringify(value)}\n`);
|
|
698
859
|
}
|
|
699
860
|
|
|
700
|
-
function normalizeBrowserAction(value) {
|
|
701
|
-
const action = String(value || "").trim();
|
|
702
|
-
if (!["navigate", "click", "fill", "select", "check", "uncheck", "focus", "press", "submit", "reload", "back", "forward"].includes(action)) {
|
|
703
|
-
throw new Error("unsupported browser action");
|
|
704
|
-
}
|
|
705
|
-
return action;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function normalizeFormAction(value) {
|
|
709
|
-
const action = String(value || "").trim();
|
|
710
|
-
if (!["fill", "select", "check", "uncheck", "click"].includes(action)) throw new Error("unsupported form field action");
|
|
711
|
-
return action;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function normalizeBrowserSelector(value, action) {
|
|
715
|
-
if (["navigate", "reload", "back", "forward"].includes(action)) return null;
|
|
716
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
|
|
717
|
-
const allowed = new Set(["css", "id", "name", "label", "text", "role", "placeholder", "index"]);
|
|
718
|
-
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown selector field: ${key}`);
|
|
719
|
-
const output = {};
|
|
720
|
-
for (const key of ["css", "id", "name", "label", "text", "role", "placeholder"]) {
|
|
721
|
-
if (value[key] !== undefined) output[key] = optionalString(value[key], `selector.${key}`, 2000);
|
|
722
|
-
}
|
|
723
|
-
if (value.index !== undefined) output.index = optionalInteger(value.index, "selector.index", 0, 10000);
|
|
724
|
-
if (!Object.keys(output).length) throw new Error("selector requires at least one field");
|
|
725
|
-
return output;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function validateNavigationUrl(value) {
|
|
729
|
-
if (!value) throw new Error("navigate requires url");
|
|
730
|
-
let parsed;
|
|
731
|
-
try { parsed = new URL(value); } catch { throw new Error("url must be an absolute URL"); }
|
|
732
|
-
if (!["http:", "https:", "file:"].includes(parsed.protocol)) throw new Error("url protocol must be http, https, or file");
|
|
733
|
-
return parsed.href;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
861
|
function normalizeUploadFilename(value, { derived = false } = {}) {
|
|
737
862
|
let name = String(value || "");
|
|
738
863
|
if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
|
|
@@ -760,13 +885,6 @@ function optionalStringArray(value, label, maxItems, maxLength) {
|
|
|
760
885
|
});
|
|
761
886
|
}
|
|
762
887
|
|
|
763
|
-
function normalizeWait(value) {
|
|
764
|
-
if (value === undefined || value === null || value === "") return "none";
|
|
765
|
-
const wait = String(value);
|
|
766
|
-
if (!["none", "domcontentloaded", "complete"].includes(wait)) throw new Error("wait_for must be none, domcontentloaded, or complete");
|
|
767
|
-
return wait;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
888
|
function boundedValue(value, label) {
|
|
771
889
|
const string = String(value);
|
|
772
890
|
if (string.includes("\0") || string.length > MAX_FIELD_VALUE_CHARS) throw new Error(`${label} exceeds the maximum length or contains a NUL byte`);
|
|
@@ -778,23 +896,3 @@ function validateResource(value) {
|
|
|
778
896
|
if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
|
|
779
897
|
return name;
|
|
780
898
|
}
|
|
781
|
-
|
|
782
|
-
function optionalString(value, label, maxLength) {
|
|
783
|
-
if (value === undefined || value === null || value === "") return "";
|
|
784
|
-
if (typeof value !== "string" || value.includes("\0") || value.length > maxLength) throw new Error(`${label} must be a string of at most ${maxLength} characters without NUL bytes`);
|
|
785
|
-
return value;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
function optionalInteger(value, label, min, max) {
|
|
789
|
-
if (value === undefined || value === null || value === "") return null;
|
|
790
|
-
const number = Number(value);
|
|
791
|
-
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
792
|
-
return number;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function clampInt(value, fallback, min, max) {
|
|
796
|
-
if (value === undefined || value === null || value === "") return fallback;
|
|
797
|
-
const number = Number(value);
|
|
798
|
-
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`expected an integer from ${min} to ${max}`);
|
|
799
|
-
return number;
|
|
800
|
-
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const NAVIGATION_ACTIONS = new Set(["navigate", "reload", "back", "forward"]);
|
|
2
|
+
const PAGE_ACTIONS = new Set([
|
|
3
|
+
"click", "double_click", "hover", "fill", "type_text", "select", "check", "uncheck",
|
|
4
|
+
"focus", "press", "submit", "scroll_into_view",
|
|
5
|
+
]);
|
|
6
|
+
const FORM_ACTIONS = new Set(["fill", "select", "check", "uncheck", "click"]);
|
|
7
|
+
const WAIT_STATES = new Set(["attached", "detached", "visible", "hidden", "enabled", "editable", "checked", "unchecked"]);
|
|
8
|
+
const LOAD_STATES = new Set(["domcontentloaded", "complete"]);
|
|
9
|
+
const INPUT_MODES = new Set(["auto", "trusted", "dom"]);
|
|
10
|
+
|
|
11
|
+
export function normalizeBrowserAction(value) {
|
|
12
|
+
const action = String(value || "").trim();
|
|
13
|
+
if (!NAVIGATION_ACTIONS.has(action) && !PAGE_ACTIONS.has(action)) throw new Error("unsupported browser action");
|
|
14
|
+
return action;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeFormAction(value) {
|
|
18
|
+
const action = String(value || "").trim();
|
|
19
|
+
if (!FORM_ACTIONS.has(action)) throw new Error("unsupported form field action");
|
|
20
|
+
return action;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeBrowserSelector(value, action = "") {
|
|
24
|
+
if (NAVIGATION_ACTIONS.has(action)) return null;
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
|
|
26
|
+
const allowed = new Set(["ref", "css", "id", "name", "label", "text", "role", "placeholder", "index"]);
|
|
27
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown selector field: ${key}`);
|
|
28
|
+
const output = {};
|
|
29
|
+
if (value.ref !== undefined) {
|
|
30
|
+
const ref = optionalString(value.ref, "selector.ref", 100);
|
|
31
|
+
if (ref) output.ref = ref;
|
|
32
|
+
}
|
|
33
|
+
for (const key of ["css", "id", "name", "label", "text", "role", "placeholder"]) {
|
|
34
|
+
if (value[key] === undefined) continue;
|
|
35
|
+
const normalized = optionalString(value[key], `selector.${key}`, 2000);
|
|
36
|
+
if (normalized) output[key] = normalized;
|
|
37
|
+
}
|
|
38
|
+
if (value.index !== undefined) output.index = optionalInteger(value.index, "selector.index", 0, 10000);
|
|
39
|
+
if (!Object.keys(output).length) throw new Error("selector requires at least one field");
|
|
40
|
+
if (output.ref && Object.keys(output).length !== 1) throw new Error("selector.ref cannot be combined with other selector fields");
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeInputMode(value) {
|
|
45
|
+
if (value === undefined || value === null || value === "") return "auto";
|
|
46
|
+
const mode = String(value);
|
|
47
|
+
if (!INPUT_MODES.has(mode)) throw new Error("input_mode must be auto, trusted, or dom");
|
|
48
|
+
return mode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeNavigationWait(value) {
|
|
52
|
+
if (value === undefined || value === null || value === "") return "none";
|
|
53
|
+
const wait = String(value);
|
|
54
|
+
if (!["none", ...LOAD_STATES].includes(wait)) throw new Error("wait_for must be none, domcontentloaded, or complete");
|
|
55
|
+
return wait;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeBrowserWait(args = {}) {
|
|
59
|
+
const selector = args.selector === undefined ? null : normalizeBrowserSelector(args.selector, "wait");
|
|
60
|
+
const state = args.state === undefined || args.state === null || args.state === ""
|
|
61
|
+
? (selector ? "visible" : "")
|
|
62
|
+
: String(args.state);
|
|
63
|
+
if (state && !WAIT_STATES.has(state)) throw new Error("state is not a supported browser wait state");
|
|
64
|
+
if (state && !selector) throw new Error("state requires selector");
|
|
65
|
+
const text = optionalString(args.text, "text", 4000);
|
|
66
|
+
const urlContains = optionalString(args.url_contains, "url_contains", 32768);
|
|
67
|
+
const loadState = args.load_state === undefined || args.load_state === null || args.load_state === "" ? "" : String(args.load_state);
|
|
68
|
+
if (loadState && !LOAD_STATES.has(loadState)) throw new Error("load_state must be domcontentloaded or complete");
|
|
69
|
+
if (!selector && !text && !urlContains && !loadState) throw new Error("browser_wait requires selector, text, url_contains, or load_state");
|
|
70
|
+
const timeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
|
|
71
|
+
return {
|
|
72
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
73
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
74
|
+
selector,
|
|
75
|
+
state,
|
|
76
|
+
text,
|
|
77
|
+
urlContains,
|
|
78
|
+
loadState,
|
|
79
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function normalizeTabCommand(args = {}) {
|
|
84
|
+
const action = String(args.action || "").trim();
|
|
85
|
+
if (!["new", "activate", "close"].includes(action)) throw new Error("browser tab action must be new, activate, or close");
|
|
86
|
+
const tabId = optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER);
|
|
87
|
+
const rawUrl = optionalString(args.url, "url", 32768);
|
|
88
|
+
if (["activate", "close"].includes(action) && !tabId) throw new Error(`${action} requires tab_id`);
|
|
89
|
+
if (action !== "new" && rawUrl) throw new Error("url is only valid for new tabs");
|
|
90
|
+
return {
|
|
91
|
+
action,
|
|
92
|
+
tabId,
|
|
93
|
+
url: rawUrl ? validateNavigationUrl(rawUrl) : "",
|
|
94
|
+
active: args.active !== false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validateNavigationUrl(value) {
|
|
99
|
+
if (!value) throw new Error("navigate requires url");
|
|
100
|
+
let parsed;
|
|
101
|
+
try { parsed = new URL(value); } catch { throw new Error("url must be an absolute URL"); }
|
|
102
|
+
if (!["http:", "https:", "file:"].includes(parsed.protocol)) throw new Error("url protocol must be http, https, or file");
|
|
103
|
+
return parsed.href;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function optionalString(value, label, maxLength) {
|
|
107
|
+
if (value === undefined || value === null || value === "") return "";
|
|
108
|
+
if (typeof value !== "string" || value.includes("\0") || value.length > maxLength) {
|
|
109
|
+
throw new Error(`${label} must be a string of at most ${maxLength} characters without NUL bytes`);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function optionalInteger(value, label, min, max) {
|
|
115
|
+
if (value === undefined || value === null || value === "") return null;
|
|
116
|
+
const number = Number(value);
|
|
117
|
+
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
118
|
+
return number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function clampInt(value, fallback, min, max) {
|
|
122
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
123
|
+
const number = Number(value);
|
|
124
|
+
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`expected an integer from ${min} to ${max}`);
|
|
125
|
+
return number;
|
|
126
|
+
}
|