@browserbridge/bbx 1.7.4 → 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 +1 -1
- 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 +271 -74
- 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 +5 -2
- package/packages/mcp-server/src/bin.js +10 -1
- package/packages/mcp-server/src/guidance.js +1 -1
- package/packages/mcp-server/src/handlers-capture.js +8 -5
- package/packages/mcp-server/src/handlers-dom.js +2 -2
- package/packages/mcp-server/src/handlers-navigation.js +20 -9
- package/packages/mcp-server/src/handlers-page.js +111 -111
- package/packages/mcp-server/src/handlers-utils.js +268 -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 +27 -19
- package/packages/protocol/src/defaults.js +2 -0
- package/packages/protocol/src/protocol.js +106 -5
- package/packages/protocol/src/types.ts +19 -1
- package/skills/browser-bridge/SKILL.md +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { BRIDGE_METHOD_REGISTRY, BRIDGE_METHODS } from '../../protocol/src/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseCommaList,
|
|
6
|
+
parseIntArg,
|
|
7
|
+
parseNumberArg,
|
|
8
|
+
parsePropertyAssignments,
|
|
9
|
+
} from './cli-helpers.js';
|
|
5
10
|
|
|
6
11
|
/** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
|
|
7
12
|
/** @typedef {import('./types.js').ShortcutCommand} ShortcutCommand */
|
|
@@ -200,8 +205,8 @@ export const SHORTCUT_COMMANDS = {
|
|
|
200
205
|
scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
|
|
201
206
|
if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
|
|
202
207
|
return {
|
|
203
|
-
top: r[0] ?
|
|
204
|
-
left: r[1] ?
|
|
208
|
+
top: r[0] ? parseNumberArg(r[0], 'top') : undefined,
|
|
209
|
+
left: r[1] ? parseNumberArg(r[1], 'left') : undefined,
|
|
205
210
|
};
|
|
206
211
|
}),
|
|
207
212
|
resize: createShortcutCommand('viewport.resize', 'bbx resize <width> <height>', (r) => {
|
|
@@ -256,11 +261,12 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
256
261
|
'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
|
|
257
262
|
'bbx status Check bridge connection',
|
|
258
263
|
'bbx doctor Diagnose install, daemon, extension, and access readiness',
|
|
259
|
-
'bbx restart Restart the local
|
|
260
|
-
'bbx proxy enable [--port 9223] [--bind-host
|
|
264
|
+
'bbx restart Restart the local daemon and running MCP servers',
|
|
265
|
+
'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
|
|
266
|
+
' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
|
|
261
267
|
'bbx proxy disable Disable proxy mode and restart the daemon',
|
|
262
268
|
'bbx proxy status Show proxy config and daemon reachability',
|
|
263
|
-
'bbx remote add <name> <host:port> --token <token>
|
|
269
|
+
'bbx remote add <name> <host:port> (--token <token>|--token-file <path>) Add a remote Browser Bridge destination',
|
|
264
270
|
'bbx remote list List remote destinations',
|
|
265
271
|
'bbx remote test <name> Ping a remote destination',
|
|
266
272
|
'bbx remote remove <name> Remove a remote destination',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
|
|
8
9
|
import { installMcpConfig } from './mcp-config.js';
|
|
@@ -452,10 +453,44 @@ async function installManagedSkill(skillName, target, targetDir) {
|
|
|
452
453
|
throw new Error(`Refusing to overwrite unmanaged skill directory: ${targetDir}`);
|
|
453
454
|
}
|
|
454
455
|
|
|
455
|
-
await fs.promises.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
456
|
+
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
|
457
|
+
const suffix = `${process.pid}-${randomUUID()}`;
|
|
458
|
+
const temporaryDir = `${targetDir}.tmp-${suffix}`;
|
|
459
|
+
const backupDir = `${targetDir}.backup-${suffix}`;
|
|
460
|
+
let backupCreated = false;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
await copyDir(sourceDir, temporaryDir);
|
|
464
|
+
await applyManagedSkillPatches(skillName, target, temporaryDir);
|
|
465
|
+
await fs.promises.writeFile(
|
|
466
|
+
path.join(temporaryDir, managedSentinelFilename),
|
|
467
|
+
formatManagedSkillSentinel(skillName),
|
|
468
|
+
'utf8'
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
if (targetExists) {
|
|
472
|
+
await fs.promises.rename(targetDir, backupDir);
|
|
473
|
+
backupCreated = true;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
await fs.promises.rename(temporaryDir, targetDir);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (backupCreated) {
|
|
479
|
+
await fs.promises.rename(backupDir, targetDir);
|
|
480
|
+
backupCreated = false;
|
|
481
|
+
}
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
if (backupCreated) {
|
|
485
|
+
await fs.promises.rm(backupDir, { recursive: true, force: true }).catch(() => {});
|
|
486
|
+
backupCreated = false;
|
|
487
|
+
}
|
|
488
|
+
} finally {
|
|
489
|
+
await fs.promises.rm(temporaryDir, { recursive: true, force: true }).catch(() => {});
|
|
490
|
+
if (backupCreated && !(await pathExists(targetDir))) {
|
|
491
|
+
await fs.promises.rename(backupDir, targetDir).catch(() => {});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
459
494
|
}
|
|
460
495
|
|
|
461
496
|
/**
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { BridgeError } from '../../protocol/src/index.js';
|
|
3
4
|
import {
|
|
4
5
|
createToolResult,
|
|
5
6
|
getToolTokenBudget,
|
|
@@ -109,7 +110,7 @@ export async function handleCaptureTool(args) {
|
|
|
109
110
|
const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
|
|
110
111
|
tabId: requestedTabId,
|
|
111
112
|
source: REQUEST_SOURCE,
|
|
112
|
-
tokenBudget: getToolTokenBudget(args),
|
|
113
|
+
tokenBudget: entry.method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
|
|
113
114
|
});
|
|
114
115
|
if (!response.ok) {
|
|
115
116
|
return summarizeToolResponse(response, entry.method);
|
|
@@ -127,15 +128,15 @@ export async function handleCaptureTool(args) {
|
|
|
127
128
|
* @param {BridgeMethod} method
|
|
128
129
|
* @returns {ToolResult}
|
|
129
130
|
*/
|
|
130
|
-
function createScreenshotResult(response, method) {
|
|
131
|
+
export function createScreenshotResult(response, method) {
|
|
131
132
|
const result = toRecord(response.result);
|
|
132
133
|
if (typeof result.image !== 'string') {
|
|
133
|
-
return summarizeToolError(`${method} returned no image data.`);
|
|
134
|
+
return summarizeToolError(new Error(`${method} returned no image data.`));
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
const image = normalizeBase64Image(result.image);
|
|
137
138
|
if (!image) {
|
|
138
|
-
return summarizeToolError(`${method} returned invalid base64 image data.`);
|
|
139
|
+
return summarizeToolError(new Error(`${method} returned invalid base64 image data.`));
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
const rect = boundedRect(result.rect);
|
|
@@ -320,7 +321,9 @@ export async function handleInputTool(args) {
|
|
|
320
321
|
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
321
322
|
const elementTarget = () => {
|
|
322
323
|
const target = createInputTarget(args.elementRef, args.selector);
|
|
323
|
-
if (!target)
|
|
324
|
+
if (!target) {
|
|
325
|
+
throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
|
|
326
|
+
}
|
|
324
327
|
return target;
|
|
325
328
|
};
|
|
326
329
|
|
|
@@ -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') {
|