@browserbridge/bbx 1.7.3 → 1.7.5
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/package.json +7 -7
- package/packages/agent-client/src/atomic-write.js +99 -0
- package/packages/agent-client/src/cli-helpers.js +20 -3
- package/packages/agent-client/src/cli.js +273 -76
- package/packages/agent-client/src/command-registry.js +12 -6
- package/packages/agent-client/src/install.js +39 -4
- package/packages/agent-client/src/mcp-config.js +28 -14
- package/packages/agent-client/src/remotes.js +93 -15
- package/packages/agent-client/src/runtime.js +6 -3
- package/packages/mcp-server/src/bin.js +10 -1
- package/packages/mcp-server/src/guidance.js +2 -2
- package/packages/mcp-server/src/handlers-capture.js +62 -32
- package/packages/mcp-server/src/handlers-dom.js +2 -2
- package/packages/mcp-server/src/handlers-navigation.js +30 -9
- package/packages/mcp-server/src/handlers-page.js +170 -143
- package/packages/mcp-server/src/handlers-utils.js +277 -8
- package/packages/mcp-server/src/handlers.js +31 -10
- package/packages/mcp-server/src/lifecycle.js +413 -0
- package/packages/mcp-server/src/server.js +59 -30
- package/packages/native-host/src/daemon.js +3 -1
- package/packages/protocol/src/capabilities.js +1 -1
- package/packages/protocol/src/defaults.js +2 -0
- package/packages/protocol/src/protocol.js +122 -9
- package/packages/protocol/src/registry.js +14 -14
- package/packages/protocol/src/summary.js +61 -30
- package/packages/protocol/src/types.ts +30 -1
- package/skills/browser-bridge/SKILL.md +4 -4
- package/skills/browser-bridge/references/interaction.md +1 -1
- package/skills/browser-bridge/references/protocol.md +3 -3
|
@@ -4,6 +4,8 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
8
|
+
|
|
7
9
|
/** @typedef {import('./types.js').McpClientName} McpClientName */
|
|
8
10
|
|
|
9
11
|
/** @type {McpClientName[]} */
|
|
@@ -491,6 +493,8 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
491
493
|
const parsed = JSON.parse(raw);
|
|
492
494
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
493
495
|
existing = parsed;
|
|
496
|
+
} else {
|
|
497
|
+
throw new Error('MCP config root must be a JSON object.');
|
|
494
498
|
}
|
|
495
499
|
} catch (error) {
|
|
496
500
|
if (!isMissingFileError(error)) {
|
|
@@ -519,8 +523,9 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
519
523
|
},
|
|
520
524
|
};
|
|
521
525
|
|
|
522
|
-
await
|
|
523
|
-
|
|
526
|
+
await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
|
|
527
|
+
encoding: 'utf8',
|
|
528
|
+
});
|
|
524
529
|
stdout.write(`Wrote ${configPath}\n`);
|
|
525
530
|
}
|
|
526
531
|
|
|
@@ -533,13 +538,14 @@ async function installCodexMcpConfig(configPath, stdout) {
|
|
|
533
538
|
let raw = '';
|
|
534
539
|
try {
|
|
535
540
|
raw = await fs.promises.readFile(configPath, 'utf8');
|
|
536
|
-
} catch {
|
|
537
|
-
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (!isMissingFileError(error)) {
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
538
545
|
}
|
|
539
546
|
|
|
540
547
|
const updated = upsertCodexServerBlock(raw);
|
|
541
|
-
await
|
|
542
|
-
await fs.promises.writeFile(configPath, updated, 'utf8');
|
|
548
|
+
await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
|
|
543
549
|
stdout.write(`Wrote ${configPath}\n`);
|
|
544
550
|
}
|
|
545
551
|
|
|
@@ -558,10 +564,15 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
558
564
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
559
565
|
existing = parsed;
|
|
560
566
|
} else {
|
|
567
|
+
throw new Error('MCP config root must be a JSON object.');
|
|
568
|
+
}
|
|
569
|
+
} catch (error) {
|
|
570
|
+
if (isMissingFileError(error)) {
|
|
561
571
|
return false;
|
|
562
572
|
}
|
|
563
|
-
|
|
564
|
-
|
|
573
|
+
throw new Error(
|
|
574
|
+
`Cannot remove Browser Bridge from ${configPath}: existing MCP config is not readable valid JSON.`
|
|
575
|
+
);
|
|
565
576
|
}
|
|
566
577
|
|
|
567
578
|
const shape = getMcpConfigShapeForPath(clientName, configPath);
|
|
@@ -584,8 +595,9 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
584
595
|
merged[topKey] = updatedBlock;
|
|
585
596
|
}
|
|
586
597
|
|
|
587
|
-
await
|
|
588
|
-
|
|
598
|
+
await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
|
|
599
|
+
encoding: 'utf8',
|
|
600
|
+
});
|
|
589
601
|
stdout.write(`Removed ${configPath}\n`);
|
|
590
602
|
return true;
|
|
591
603
|
}
|
|
@@ -599,8 +611,11 @@ async function removeCodexMcpConfig(configPath, stdout) {
|
|
|
599
611
|
let raw = '';
|
|
600
612
|
try {
|
|
601
613
|
raw = await fs.promises.readFile(configPath, 'utf8');
|
|
602
|
-
} catch {
|
|
603
|
-
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (isMissingFileError(error)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
throw error;
|
|
604
619
|
}
|
|
605
620
|
|
|
606
621
|
if (!hasCodexServerBlock(raw)) {
|
|
@@ -608,8 +623,7 @@ async function removeCodexMcpConfig(configPath, stdout) {
|
|
|
608
623
|
}
|
|
609
624
|
|
|
610
625
|
const updated = removeCodexServerBlock(raw);
|
|
611
|
-
await
|
|
612
|
-
await fs.promises.writeFile(configPath, updated, 'utf8');
|
|
626
|
+
await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
|
|
613
627
|
stdout.write(`Removed ${configPath}\n`);
|
|
614
628
|
return true;
|
|
615
629
|
}
|
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/config.js';
|
|
7
7
|
import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
|
|
8
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
8
9
|
import { BridgeClient } from './client.js';
|
|
9
10
|
|
|
10
11
|
const REMOTES_FILENAME = 'remotes.json';
|
|
@@ -121,15 +122,13 @@ export async function readRemoteConfig(options = {}) {
|
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
124
|
* @param {RemoteConfig} config
|
|
124
|
-
* @param {{ configPath?: string
|
|
125
|
+
* @param {{ configPath?: string }} [options={}]
|
|
125
126
|
* @returns {Promise<void>}
|
|
126
127
|
*/
|
|
127
128
|
export async function writeRemoteConfig(config, options = {}) {
|
|
128
129
|
const configPath = options.configPath ?? getRemoteConfigPath();
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
await mkdir(path.dirname(configPath), { recursive: true });
|
|
132
|
-
await writeFile(configPath, `${JSON.stringify({ remotes: config.remotes }, null, 2)}\n`, {
|
|
130
|
+
const remotes = config.remotes.map(validateRemoteDestination);
|
|
131
|
+
await atomicWriteFile(configPath, `${JSON.stringify({ remotes }, null, 2)}\n`, {
|
|
133
132
|
encoding: 'utf8',
|
|
134
133
|
mode: 0o600,
|
|
135
134
|
});
|
|
@@ -140,15 +139,16 @@ export async function writeRemoteConfig(config, options = {}) {
|
|
|
140
139
|
* @returns {Promise<RemoteDestination>}
|
|
141
140
|
*/
|
|
142
141
|
export async function addRemoteDestination(remote) {
|
|
142
|
+
const validated = validateRemoteDestination(remote);
|
|
143
143
|
const config = await readRemoteConfig();
|
|
144
|
-
const existing = config.remotes.findIndex((entry) => entry.id ===
|
|
144
|
+
const existing = config.remotes.findIndex((entry) => entry.id === validated.id);
|
|
145
145
|
if (existing === -1) {
|
|
146
|
-
config.remotes.push(
|
|
146
|
+
config.remotes.push(validated);
|
|
147
147
|
} else {
|
|
148
|
-
config.remotes[existing] =
|
|
148
|
+
config.remotes[existing] = validated;
|
|
149
149
|
}
|
|
150
150
|
await writeRemoteConfig(config);
|
|
151
|
-
return
|
|
151
|
+
return validated;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
@@ -180,7 +180,7 @@ export async function listBridgeDestinations() {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean }} ProxyEnableOptions
|
|
183
|
+
* @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean, unsafePlaintext?: boolean }} ProxyEnableOptions
|
|
184
184
|
*/
|
|
185
185
|
|
|
186
186
|
/**
|
|
@@ -197,14 +197,92 @@ export async function listBridgeDestinations() {
|
|
|
197
197
|
*/
|
|
198
198
|
export function resolveProxyEnableSettings(existing, options, generateToken) {
|
|
199
199
|
const port = options.port ?? existing?.port ?? DEFAULT_REMOTE_PORT;
|
|
200
|
-
const bindHost = options.bindHost ?? existing?.bindHost ?? '
|
|
200
|
+
const bindHost = options.bindHost ?? existing?.bindHost ?? '127.0.0.1';
|
|
201
201
|
if (options.token) {
|
|
202
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
port,
|
|
204
|
+
bindHost,
|
|
205
|
+
token: validateAuthToken(options.token),
|
|
206
|
+
tokenSource: 'explicit',
|
|
207
|
+
};
|
|
203
208
|
}
|
|
204
|
-
|
|
205
|
-
|
|
209
|
+
const existingToken = normalizeBridgeAuthToken(existing?.token);
|
|
210
|
+
if (!options.rotateToken && existingToken) {
|
|
211
|
+
return { port, bindHost, token: existingToken, tokenSource: 'existing' };
|
|
206
212
|
}
|
|
207
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
port,
|
|
215
|
+
bindHost,
|
|
216
|
+
token: validateAuthToken(generateToken()),
|
|
217
|
+
tokenSource: 'generated',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Require an explicit acknowledgement before creating or changing a raw TCP
|
|
223
|
+
* listener that is reachable beyond the local machine. A stored non-loopback
|
|
224
|
+
* host may be reused without breaking an existing installation.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ bindHost: string } | null} existing
|
|
227
|
+
* @param {ProxyEnableOptions} options
|
|
228
|
+
* @param {string} bindHost
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
export function assertProxyBindSafety(existing, options, bindHost) {
|
|
232
|
+
if (
|
|
233
|
+
isLoopbackHost(bindHost) ||
|
|
234
|
+
options.unsafePlaintext === true ||
|
|
235
|
+
(options.bindHost === undefined && existing?.bindHost === bindHost)
|
|
236
|
+
) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
throw new Error(
|
|
240
|
+
'Refusing to bind Browser Bridge raw TCP to a non-loopback host without --unsafe-plaintext. This transport is unencrypted; keep the default 127.0.0.1 bind and use an SSH tunnel instead.'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {string} host
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
export function isLoopbackHost(host) {
|
|
249
|
+
const normalized = host
|
|
250
|
+
.trim()
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/^\[|\]$/gu, '');
|
|
253
|
+
const ipv4Parts = normalized.split('.').map(Number);
|
|
254
|
+
const isIpv4Loopback =
|
|
255
|
+
ipv4Parts.length === 4 &&
|
|
256
|
+
ipv4Parts[0] === 127 &&
|
|
257
|
+
ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
|
|
258
|
+
return (
|
|
259
|
+
normalized === 'localhost' ||
|
|
260
|
+
normalized === '::1' ||
|
|
261
|
+
normalized === '0:0:0:0:0:0:0:1' ||
|
|
262
|
+
isIpv4Loopback
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {RemoteDestination} remote
|
|
268
|
+
* @returns {RemoteDestination}
|
|
269
|
+
*/
|
|
270
|
+
function validateRemoteDestination(remote) {
|
|
271
|
+
const id = normalizeDestinationId(remote.id);
|
|
272
|
+
const { host, port } = parseRemoteEndpoint(`${remote.host}:${remote.port}`);
|
|
273
|
+
return { id, host, port, token: validateAuthToken(remote.token) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {unknown} token
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
function validateAuthToken(token) {
|
|
281
|
+
const normalized = normalizeBridgeAuthToken(token);
|
|
282
|
+
if (!normalized) {
|
|
283
|
+
throw new Error('Bridge auth token must be a UUID or 32-256 URL-safe characters.');
|
|
284
|
+
}
|
|
285
|
+
return normalized;
|
|
208
286
|
}
|
|
209
287
|
|
|
210
288
|
/**
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '../../native-host/src/daemon-process.js';
|
|
20
20
|
import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
|
|
21
21
|
import {
|
|
22
|
+
BridgeError,
|
|
22
23
|
CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
|
|
23
24
|
DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
24
25
|
getBridgeOperationTimeoutMs,
|
|
@@ -102,12 +103,14 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
|
|
|
102
103
|
);
|
|
103
104
|
|
|
104
105
|
if (!response.ok) {
|
|
105
|
-
throw new
|
|
106
|
+
throw new BridgeError(response.error.code, response.error.message, response.error.details);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const result = /** @type {{ nodes: Array<{ elementRef: string }> }} */ (response.result);
|
|
109
110
|
if (!result.nodes || result.nodes.length === 0) {
|
|
110
|
-
throw new
|
|
111
|
+
throw new BridgeError('INVALID_REQUEST', `No element found for selector "${refOrSelector}".`, {
|
|
112
|
+
selector: refOrSelector,
|
|
113
|
+
});
|
|
111
114
|
}
|
|
112
115
|
return result.nodes[0].elementRef;
|
|
113
116
|
}
|
|
@@ -350,7 +353,7 @@ export async function checkNativeHostManifestHealth(browserManifests) {
|
|
|
350
353
|
*/
|
|
351
354
|
export async function checkUnwritableBridgePaths() {
|
|
352
355
|
// The root-owned-files scenario comes from sudo installs, which do not exist
|
|
353
|
-
// on Windows
|
|
356
|
+
// on Windows - and fs.access(W_OK) there only reflects the read-only file
|
|
354
357
|
// attribute, so it would produce false positives.
|
|
355
358
|
if (os.platform() === 'win32') {
|
|
356
359
|
return [];
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
|
|
7
|
+
import { tryStartMcpProcessControl } from './lifecycle.js';
|
|
7
8
|
import { startBridgeMcpServer } from './server.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* @typedef {{
|
|
11
12
|
* start?: () => Promise<void>,
|
|
13
|
+
* startControl?: typeof tryStartMcpProcessControl,
|
|
12
14
|
* argv?: string[],
|
|
13
15
|
* stdout?: { write: (chunk: string) => unknown },
|
|
14
16
|
* stderr?: { write: (chunk: string) => unknown },
|
|
@@ -33,6 +35,7 @@ const MCP_USAGE = [
|
|
|
33
35
|
export async function runBridgeMcpCli(options = {}) {
|
|
34
36
|
const {
|
|
35
37
|
start = startBridgeMcpServer,
|
|
38
|
+
startControl = tryStartMcpProcessControl,
|
|
36
39
|
argv = process.argv.slice(2),
|
|
37
40
|
stdout = process.stdout,
|
|
38
41
|
stderr = process.stderr,
|
|
@@ -45,7 +48,13 @@ export async function runBridgeMcpCli(options = {}) {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
try {
|
|
48
|
-
await
|
|
51
|
+
const control = await startControl();
|
|
52
|
+
try {
|
|
53
|
+
await start();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
await control?.dispose();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
49
58
|
return 0;
|
|
50
59
|
} catch (error) {
|
|
51
60
|
const message = error instanceof Error ? error.stack || error.message : String(error);
|
|
@@ -10,7 +10,7 @@ export const MCP_SERVER_INSTRUCTIONS = [
|
|
|
10
10
|
'In permission-ask hosts, use browser_call as the default Browser Bridge MCP tool so the user can approve one BBX tool instead of separate browser_status, browser_page, browser_dom, browser_input, and other tools.',
|
|
11
11
|
'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
|
|
12
12
|
'Start with browser_call method health.ping. If window access is disabled, call browser_call method access.request once, ask the user to click Enable in the Browser Bridge popup or side panel, then retry once.',
|
|
13
|
-
'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, or layout.get_box_model. Use browser_batch for parallel reads and keep limits tight before widening.',
|
|
13
|
+
'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, or layout.get_box_model. Use browser_batch only for parallel reads; run mutations sequentially and keep limits tight before widening.',
|
|
14
14
|
'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
|
|
15
15
|
'Escalate to screenshot.capture_element, screenshot.capture_region, dom.get_accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
|
|
16
16
|
'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
|
|
@@ -103,7 +103,7 @@ function createGuidePrompt() {
|
|
|
103
103
|
'2. In permission-ask hosts, use `browser_call` as the default tool so the user can approve one BBX MCP tool instead of separate tools for status, page, DOM, input, and patches.',
|
|
104
104
|
'3. Start with `browser_call` method `health.ping`. If access is disabled, call `browser_call` method `access.request` once, ask the user to click Enable, then retry once.',
|
|
105
105
|
'4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, and `styles.get_computed`. Use `browser_batch` for parallel reads.',
|
|
106
|
-
'5. `browser_call` accepts explicit method limits
|
|
106
|
+
'5. `browser_call` accepts `budgetPreset`; explicit method limits override preset values. Keep limits tight and widen only when results are truncated.',
|
|
107
107
|
'6. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
|
|
108
108
|
'7. Escalate to screenshots, accessibility tree, `page.evaluate`, viewport resize, or CDP only when structured reads cannot answer.',
|
|
109
109
|
'8. Use `patch.apply_styles` or `patch.apply_dom` for temporary experiments and rollback before finishing unless the user asks to keep patches.',
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { BridgeError } from '../../protocol/src/index.js';
|
|
3
4
|
import {
|
|
4
5
|
createToolResult,
|
|
5
6
|
getToolTokenBudget,
|
|
6
7
|
REQUEST_SOURCE,
|
|
7
8
|
requestBridgeWithRetry,
|
|
8
9
|
resolveToolRef,
|
|
9
|
-
resolveRef,
|
|
10
10
|
summarizeToolError,
|
|
11
11
|
summarizeToolResponse,
|
|
12
12
|
withToolClient,
|
|
@@ -110,7 +110,7 @@ export async function handleCaptureTool(args) {
|
|
|
110
110
|
const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
|
|
111
111
|
tabId: requestedTabId,
|
|
112
112
|
source: REQUEST_SOURCE,
|
|
113
|
-
tokenBudget: getToolTokenBudget(args),
|
|
113
|
+
tokenBudget: entry.method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
|
|
114
114
|
});
|
|
115
115
|
if (!response.ok) {
|
|
116
116
|
return summarizeToolResponse(response, entry.method);
|
|
@@ -128,15 +128,15 @@ export async function handleCaptureTool(args) {
|
|
|
128
128
|
* @param {BridgeMethod} method
|
|
129
129
|
* @returns {ToolResult}
|
|
130
130
|
*/
|
|
131
|
-
function createScreenshotResult(response, method) {
|
|
131
|
+
export function createScreenshotResult(response, method) {
|
|
132
132
|
const result = toRecord(response.result);
|
|
133
133
|
if (typeof result.image !== 'string') {
|
|
134
|
-
return summarizeToolError(`${method} returned no image data.`);
|
|
134
|
+
return summarizeToolError(new Error(`${method} returned no image data.`));
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
const image = normalizeBase64Image(result.image);
|
|
138
138
|
if (!image) {
|
|
139
|
-
return summarizeToolError(`${method} returned invalid base64 image data.`);
|
|
139
|
+
return summarizeToolError(new Error(`${method} returned invalid base64 image data.`));
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
const rect = boundedRect(result.rect);
|
|
@@ -269,6 +269,7 @@ export const INPUT_ACTION_METHODS = {
|
|
|
269
269
|
click: 'input.click',
|
|
270
270
|
focus: 'input.focus',
|
|
271
271
|
type: 'input.type',
|
|
272
|
+
fill: 'input.fill',
|
|
272
273
|
press_key: 'input.press_key',
|
|
273
274
|
cdp_press_key: 'cdp.dispatch_key_event',
|
|
274
275
|
set_checked: 'input.set_checked',
|
|
@@ -279,13 +280,30 @@ export const INPUT_ACTION_METHODS = {
|
|
|
279
280
|
};
|
|
280
281
|
|
|
281
282
|
/**
|
|
282
|
-
*
|
|
283
|
+
* Keep selector-based actions atomic instead of resolving a ref in a separate
|
|
284
|
+
* bridge request that can race with a page rerender.
|
|
285
|
+
*
|
|
286
|
+
* @param {unknown} elementRef
|
|
287
|
+
* @param {unknown} selector
|
|
288
|
+
* @returns {import('../../protocol/src/types.js').InputTarget | null}
|
|
289
|
+
*/
|
|
290
|
+
function createInputTarget(elementRef, selector) {
|
|
291
|
+
if (typeof elementRef === 'string' && elementRef) return { elementRef };
|
|
292
|
+
if (typeof selector === 'string' && selector) return { selector };
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
283
298
|
* @returns {Promise<ToolResult>}
|
|
284
299
|
*/
|
|
285
300
|
export async function handleInputTool(args) {
|
|
286
301
|
if (args.action === 'type' && !hasText(args.text)) {
|
|
287
302
|
return summarizeToolError('text is required for input.type.');
|
|
288
303
|
}
|
|
304
|
+
if (args.action === 'fill' && typeof args.value !== 'string') {
|
|
305
|
+
return summarizeToolError('value is required for input.fill.');
|
|
306
|
+
}
|
|
289
307
|
if ((args.action === 'press_key' || args.action === 'cdp_press_key') && !hasText(args.key)) {
|
|
290
308
|
return summarizeToolError('key is required for key input actions.');
|
|
291
309
|
}
|
|
@@ -301,9 +319,13 @@ export async function handleInputTool(args) {
|
|
|
301
319
|
return withToolClient(
|
|
302
320
|
async (client) => {
|
|
303
321
|
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
304
|
-
const elementTarget =
|
|
305
|
-
|
|
306
|
-
|
|
322
|
+
const elementTarget = () => {
|
|
323
|
+
const target = createInputTarget(args.elementRef, args.selector);
|
|
324
|
+
if (!target) {
|
|
325
|
+
throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
|
|
326
|
+
}
|
|
327
|
+
return target;
|
|
328
|
+
};
|
|
307
329
|
|
|
308
330
|
switch (args.action) {
|
|
309
331
|
case 'click': {
|
|
@@ -311,7 +333,7 @@ export async function handleInputTool(args) {
|
|
|
311
333
|
client,
|
|
312
334
|
'input.click',
|
|
313
335
|
{
|
|
314
|
-
target:
|
|
336
|
+
target: elementTarget(),
|
|
315
337
|
button: args.button,
|
|
316
338
|
clickCount: args.clickCount,
|
|
317
339
|
modifiers: args.modifiers,
|
|
@@ -329,7 +351,7 @@ export async function handleInputTool(args) {
|
|
|
329
351
|
client,
|
|
330
352
|
'input.focus',
|
|
331
353
|
{
|
|
332
|
-
target:
|
|
354
|
+
target: elementTarget(),
|
|
333
355
|
},
|
|
334
356
|
{
|
|
335
357
|
tabId: requestedTabId,
|
|
@@ -344,7 +366,7 @@ export async function handleInputTool(args) {
|
|
|
344
366
|
client,
|
|
345
367
|
'input.type',
|
|
346
368
|
{
|
|
347
|
-
target:
|
|
369
|
+
target: elementTarget(),
|
|
348
370
|
text: args.text,
|
|
349
371
|
clear: args.clear,
|
|
350
372
|
submit: args.submit,
|
|
@@ -358,8 +380,25 @@ export async function handleInputTool(args) {
|
|
|
358
380
|
);
|
|
359
381
|
return summarizeToolResponse(response, 'input.type');
|
|
360
382
|
}
|
|
383
|
+
case 'fill': {
|
|
384
|
+
const response = await requestBridgeWithRetry(
|
|
385
|
+
client,
|
|
386
|
+
'input.fill',
|
|
387
|
+
{
|
|
388
|
+
target: elementTarget(),
|
|
389
|
+
value: args.value,
|
|
390
|
+
mode: args.mode,
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
tabId: requestedTabId,
|
|
394
|
+
source: REQUEST_SOURCE,
|
|
395
|
+
tokenBudget: getToolTokenBudget(args),
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
return summarizeToolResponse(response, 'input.fill');
|
|
399
|
+
}
|
|
361
400
|
case 'press_key': {
|
|
362
|
-
const target = args.elementRef || args.selector ?
|
|
401
|
+
const target = args.elementRef || args.selector ? elementTarget() : undefined;
|
|
363
402
|
const response = await requestBridgeWithRetry(
|
|
364
403
|
client,
|
|
365
404
|
'input.press_key',
|
|
@@ -398,7 +437,7 @@ export async function handleInputTool(args) {
|
|
|
398
437
|
client,
|
|
399
438
|
'input.set_checked',
|
|
400
439
|
{
|
|
401
|
-
target:
|
|
440
|
+
target: elementTarget(),
|
|
402
441
|
checked: args.checked,
|
|
403
442
|
},
|
|
404
443
|
{
|
|
@@ -414,7 +453,7 @@ export async function handleInputTool(args) {
|
|
|
414
453
|
client,
|
|
415
454
|
'input.select_option',
|
|
416
455
|
{
|
|
417
|
-
target:
|
|
456
|
+
target: elementTarget(),
|
|
418
457
|
values: args.values,
|
|
419
458
|
labels: args.labels,
|
|
420
459
|
indexes: args.indexes,
|
|
@@ -432,7 +471,7 @@ export async function handleInputTool(args) {
|
|
|
432
471
|
client,
|
|
433
472
|
'input.hover',
|
|
434
473
|
{
|
|
435
|
-
target:
|
|
474
|
+
target: elementTarget(),
|
|
436
475
|
duration: args.duration,
|
|
437
476
|
modifiers: args.modifiers,
|
|
438
477
|
},
|
|
@@ -445,21 +484,12 @@ export async function handleInputTool(args) {
|
|
|
445
484
|
return summarizeToolResponse(response, 'input.hover');
|
|
446
485
|
}
|
|
447
486
|
case 'drag': {
|
|
448
|
-
const source =
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
};
|
|
455
|
-
const destination = {
|
|
456
|
-
elementRef:
|
|
457
|
-
args.destinationElementRef ||
|
|
458
|
-
(args.destinationSelector
|
|
459
|
-
? await resolveRef(client, args.destinationSelector, requestedTabId, REQUEST_SOURCE)
|
|
460
|
-
: ''),
|
|
461
|
-
};
|
|
462
|
-
if (!source.elementRef || !destination.elementRef) {
|
|
487
|
+
const source = createInputTarget(args.sourceElementRef, args.sourceSelector);
|
|
488
|
+
const destination = createInputTarget(
|
|
489
|
+
args.destinationElementRef,
|
|
490
|
+
args.destinationSelector
|
|
491
|
+
);
|
|
492
|
+
if (!source || !destination) {
|
|
463
493
|
return summarizeToolError(
|
|
464
494
|
'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
|
|
465
495
|
);
|
|
@@ -486,7 +516,7 @@ export async function handleInputTool(args) {
|
|
|
486
516
|
client,
|
|
487
517
|
'input.scroll_into_view',
|
|
488
518
|
{
|
|
489
|
-
target:
|
|
519
|
+
target: elementTarget(),
|
|
490
520
|
},
|
|
491
521
|
{
|
|
492
522
|
tabId: requestedTabId,
|
|
@@ -104,7 +104,8 @@ export async function handleDomTool(args) {
|
|
|
104
104
|
return summarizeToolError('role is required for dom.find_by_role.');
|
|
105
105
|
}
|
|
106
106
|
if (args.action === 'query' || args.action === 'accessibility_tree') {
|
|
107
|
-
const inferred =
|
|
107
|
+
const inferred =
|
|
108
|
+
args.action === 'accessibility_tree' ? 'normal' : inferBudgetFromSelector(args);
|
|
108
109
|
const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
|
|
109
110
|
return dispatchToolAction(DOM_ACTIONS, applyTreeBudgetPreset(withBudget), 'DOM');
|
|
110
111
|
}
|
|
@@ -183,7 +184,6 @@ export const PATCH_ACTIONS = {
|
|
|
183
184
|
addClass: 'add_class',
|
|
184
185
|
removeClass: 'remove_class',
|
|
185
186
|
setTextContent: 'set_text',
|
|
186
|
-
setProperty: 'set_attribute',
|
|
187
187
|
};
|
|
188
188
|
const normalizedOperation = opMap[operation] || operation;
|
|
189
189
|
const value =
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createToolResult,
|
|
7
7
|
getBridgeDestinations,
|
|
8
8
|
getToolTokenBudget,
|
|
9
|
+
requestBridgeWithRetry,
|
|
9
10
|
summarizeToolError,
|
|
10
11
|
} from './handlers-utils.js';
|
|
11
12
|
|
|
@@ -27,15 +28,23 @@ export async function handleTabsTool(args) {
|
|
|
27
28
|
}
|
|
28
29
|
const results = await Promise.all(
|
|
29
30
|
destinations.map(async (destination) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
);
|
|
31
|
+
/** @type {import('../../agent-client/src/client.js').BridgeClient | null} */
|
|
32
|
+
let client = null;
|
|
33
33
|
try {
|
|
34
|
+
client = await createBridgeClientForDestination(
|
|
35
|
+
destination.local ? null : destination.id
|
|
36
|
+
);
|
|
34
37
|
await client.connect();
|
|
35
|
-
const response = await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
const response = await requestBridgeWithRetry(
|
|
39
|
+
client,
|
|
40
|
+
'tabs.list',
|
|
41
|
+
{},
|
|
42
|
+
{
|
|
43
|
+
tabId: null,
|
|
44
|
+
source: 'mcp',
|
|
45
|
+
tokenBudget: null,
|
|
46
|
+
}
|
|
47
|
+
);
|
|
39
48
|
if (!response.ok) {
|
|
40
49
|
return {
|
|
41
50
|
destinationId: destination.id,
|
|
@@ -58,7 +67,9 @@ export async function handleTabsTool(args) {
|
|
|
58
67
|
tabs: [],
|
|
59
68
|
};
|
|
60
69
|
} finally {
|
|
61
|
-
|
|
70
|
+
if (client) {
|
|
71
|
+
await client.close().catch(() => {});
|
|
72
|
+
}
|
|
62
73
|
}
|
|
63
74
|
})
|
|
64
75
|
);
|
|
@@ -73,7 +84,7 @@ export async function handleTabsTool(args) {
|
|
|
73
84
|
tabs,
|
|
74
85
|
destinations: results,
|
|
75
86
|
},
|
|
76
|
-
|
|
87
|
+
failures.length === results.length
|
|
77
88
|
);
|
|
78
89
|
}
|
|
79
90
|
if (args.action === 'create') {
|
|
@@ -96,6 +107,16 @@ export async function handleTabsTool(args) {
|
|
|
96
107
|
{ destinationId: args.destinationId ?? null }
|
|
97
108
|
);
|
|
98
109
|
}
|
|
110
|
+
if (args.action === 'activate') {
|
|
111
|
+
if (typeof args.tabId !== 'number') {
|
|
112
|
+
return summarizeToolError('tabId is required for tabs.activate.');
|
|
113
|
+
}
|
|
114
|
+
return callBridgeTool(
|
|
115
|
+
'tabs.activate',
|
|
116
|
+
{ tabId: args.tabId },
|
|
117
|
+
{ destinationId: args.destinationId ?? null }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
99
120
|
return summarizeToolError(`Unsupported tabs action "${args.action}".`);
|
|
100
121
|
}
|
|
101
122
|
|