@browserbridge/bbx 1.7.1 → 1.7.3
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 +4 -3
- package/packages/agent-client/src/cli.js +6 -0
- package/packages/agent-client/src/mcp-config.js +64 -65
- package/packages/agent-client/src/runtime.js +19 -0
- package/packages/mcp-server/src/guidance.js +4 -4
- package/packages/mcp-server/src/handlers-capture.js +166 -2
- package/packages/mcp-server/src/handlers-page.js +132 -72
- package/packages/mcp-server/src/handlers-utils.js +16 -66
- package/packages/mcp-server/src/handlers.js +2 -0
- package/packages/mcp-server/src/server.js +5 -1
- package/packages/native-host/src/daemon-process.js +16 -8
- package/packages/native-host/src/daemon.js +199 -79
- package/packages/protocol/src/defaults.js +17 -0
- package/packages/protocol/src/protocol.js +45 -2
- package/packages/protocol/src/registry.js +1 -1
- package/skills/browser-bridge/SKILL.md +12 -8
- package/skills/browser-bridge/references/interaction.md +10 -7
- package/skills/browser-bridge/references/protocol.md +6 -2
- package/skills/browser-bridge/references/token-efficiency.md +5 -4
- package/skills/browser-bridge/references/ui-workflows.md +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@browserbridge/bbx",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-tools",
|
|
@@ -61,8 +61,9 @@
|
|
|
61
61
|
"postinstall": "node packages/native-host/bin/postinstall.js",
|
|
62
62
|
"package:extension": "node scripts/package-extension.mjs",
|
|
63
63
|
"check:extension-zip": "node scripts/check-extension-zip.mjs",
|
|
64
|
-
"release
|
|
65
|
-
"
|
|
64
|
+
"check:release-version": "node scripts/check-release-version.mjs",
|
|
65
|
+
"release:check": "npm run check:release-version && npm run lint && npm run typecheck && npm test && npm run coverage:check && npm run package:extension && npm run check:extension-zip && npm pack --dry-run",
|
|
66
|
+
"prepublishOnly": "npm run check:release-version && npm run lint && npm run typecheck && npm test && npm run coverage:check",
|
|
66
67
|
"status": "node packages/agent-client/src/cli.js status",
|
|
67
68
|
"daemon": "node packages/native-host/bin/bridge-daemon.js",
|
|
68
69
|
"install-manifest": "node packages/native-host/bin/install-manifest.js"
|
|
@@ -512,6 +512,9 @@ async function main() {
|
|
|
512
512
|
|
|
513
513
|
if (command === 'doctor') {
|
|
514
514
|
const report = await getDoctorReport();
|
|
515
|
+
if (report.issues.length > 0) {
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
}
|
|
515
518
|
printJson({
|
|
516
519
|
ok: report.issues.length === 0,
|
|
517
520
|
summary:
|
|
@@ -1298,6 +1301,9 @@ async function ensureClientConnection() {
|
|
|
1298
1301
|
* @returns {Promise<void>}
|
|
1299
1302
|
*/
|
|
1300
1303
|
async function printSummary(response, method) {
|
|
1304
|
+
if (!response.ok) {
|
|
1305
|
+
process.exitCode = 1;
|
|
1306
|
+
}
|
|
1301
1307
|
printJson(annotateBridgeSummary(summarizeBridgeResponse(response, method), response));
|
|
1302
1308
|
}
|
|
1303
1309
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
6
|
|
|
8
7
|
/** @typedef {import('./types.js').McpClientName} McpClientName */
|
|
9
8
|
|
|
@@ -40,8 +39,8 @@ export function isMcpClientName(value) {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
const BROWSER_BRIDGE_SERVER_NAME = 'browser-bridge';
|
|
43
|
-
const
|
|
44
|
-
const
|
|
42
|
+
const MCP_COMMAND = 'bbx';
|
|
43
|
+
const MCP_ARGS = Object.freeze(['mcp', 'serve']);
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* @returns {string}
|
|
@@ -76,7 +75,7 @@ function getLegacyCopilotVsCodeConfigPath() {
|
|
|
76
75
|
|
|
77
76
|
/**
|
|
78
77
|
* @param {McpClientName} clientName
|
|
79
|
-
* @returns {{ key: string, includeType: boolean,
|
|
78
|
+
* @returns {{ key: string, includeType: boolean, keepEmptyBlock?: boolean }}
|
|
80
79
|
*/
|
|
81
80
|
export function getMcpConfigShape(clientName) {
|
|
82
81
|
return MCP_CONFIG_SHAPES[clientName] ?? { key: 'mcpServers', includeType: false };
|
|
@@ -95,21 +94,21 @@ export function getMcpConfigShape(clientName) {
|
|
|
95
94
|
*/
|
|
96
95
|
function createBaseServerConfig(clientName) {
|
|
97
96
|
const serverConfig = {
|
|
98
|
-
command:
|
|
99
|
-
args: [
|
|
97
|
+
command: MCP_COMMAND,
|
|
98
|
+
args: [...MCP_ARGS],
|
|
100
99
|
env: {},
|
|
101
100
|
};
|
|
102
101
|
|
|
103
102
|
if (clientName === 'opencode') {
|
|
104
103
|
return {
|
|
105
104
|
type: 'local',
|
|
106
|
-
command: [
|
|
105
|
+
command: [MCP_COMMAND, ...MCP_ARGS],
|
|
107
106
|
};
|
|
108
107
|
}
|
|
109
108
|
return serverConfig;
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
/** @type {Record<McpClientName, { key: string, includeType: boolean,
|
|
111
|
+
/** @type {Record<McpClientName, { key: string, includeType: boolean, keepEmptyBlock?: boolean }>} */
|
|
113
112
|
const MCP_CONFIG_SHAPES = {
|
|
114
113
|
antigravity: { key: 'mcpServers', includeType: false },
|
|
115
114
|
agents: { key: 'mcpServers', includeType: true },
|
|
@@ -117,7 +116,6 @@ const MCP_CONFIG_SHAPES = {
|
|
|
117
116
|
copilot: {
|
|
118
117
|
key: 'mcpServers',
|
|
119
118
|
includeType: true,
|
|
120
|
-
legacyKeys: ['servers'],
|
|
121
119
|
keepEmptyBlock: true,
|
|
122
120
|
},
|
|
123
121
|
cursor: { key: 'mcpServers', includeType: false },
|
|
@@ -126,6 +124,24 @@ const MCP_CONFIG_SHAPES = {
|
|
|
126
124
|
opencode: { key: 'mcp', includeType: false },
|
|
127
125
|
};
|
|
128
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Copilot CLI and VS Code use different top-level keys. Select the schema from
|
|
129
|
+
* the destination file instead of migrating one valid schema into the other.
|
|
130
|
+
*
|
|
131
|
+
* @param {McpClientName} clientName
|
|
132
|
+
* @param {string} configPath
|
|
133
|
+
* @returns {{ key: string, includeType: boolean, keepEmptyBlock?: boolean }}
|
|
134
|
+
*/
|
|
135
|
+
export function getMcpConfigShapeForPath(clientName, configPath) {
|
|
136
|
+
if (
|
|
137
|
+
clientName === 'copilot' &&
|
|
138
|
+
path.resolve(configPath) !== path.resolve(getCopilotUserConfigPath())
|
|
139
|
+
) {
|
|
140
|
+
return { key: 'servers', includeType: true, keepEmptyBlock: true };
|
|
141
|
+
}
|
|
142
|
+
return getMcpConfigShape(clientName);
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
/**
|
|
130
146
|
* @param {McpClientName} clientName
|
|
131
147
|
* @returns {Record<string, unknown>}
|
|
@@ -135,8 +151,8 @@ export function buildMcpConfig(clientName) {
|
|
|
135
151
|
return {
|
|
136
152
|
mcp_servers: {
|
|
137
153
|
[BROWSER_BRIDGE_SERVER_NAME]: {
|
|
138
|
-
command:
|
|
139
|
-
args: [
|
|
154
|
+
command: MCP_COMMAND,
|
|
155
|
+
args: [...MCP_ARGS],
|
|
140
156
|
},
|
|
141
157
|
},
|
|
142
158
|
};
|
|
@@ -267,8 +283,8 @@ export async function getMcpConfigPaths(clientName, options) {
|
|
|
267
283
|
function formatCodexServerBlock() {
|
|
268
284
|
return [
|
|
269
285
|
`[mcp_servers."${BROWSER_BRIDGE_SERVER_NAME}"]`,
|
|
270
|
-
`command = ${JSON.stringify(
|
|
271
|
-
`args = ${JSON.stringify(
|
|
286
|
+
`command = ${JSON.stringify(MCP_COMMAND)}`,
|
|
287
|
+
`args = ${JSON.stringify(MCP_ARGS)}`,
|
|
272
288
|
'',
|
|
273
289
|
].join('\n');
|
|
274
290
|
}
|
|
@@ -377,37 +393,6 @@ function removeCodexServerBlock(raw) {
|
|
|
377
393
|
return trimmed ? `${trimmed}\n` : '';
|
|
378
394
|
}
|
|
379
395
|
|
|
380
|
-
/**
|
|
381
|
-
* @param {McpClientName} clientName
|
|
382
|
-
* @returns {string[]}
|
|
383
|
-
*/
|
|
384
|
-
function getJsonMcpBlockKeys(clientName) {
|
|
385
|
-
const shape = getMcpConfigShape(clientName);
|
|
386
|
-
return [...(shape.legacyKeys ?? []), shape.key];
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Merge MCP entries from the current config key plus any legacy aliases.
|
|
391
|
-
*
|
|
392
|
-
* @param {Record<string, unknown>} config
|
|
393
|
-
* @param {McpClientName} clientName
|
|
394
|
-
* @returns {Record<string, unknown>}
|
|
395
|
-
*/
|
|
396
|
-
function getMergedJsonMcpBlock(config, clientName) {
|
|
397
|
-
/** @type {Record<string, unknown>} */
|
|
398
|
-
const merged = {};
|
|
399
|
-
|
|
400
|
-
for (const key of getJsonMcpBlockKeys(clientName)) {
|
|
401
|
-
const block = config[key];
|
|
402
|
-
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
Object.assign(merged, /** @type {Record<string, unknown>} */ (block));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return merged;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
396
|
/**
|
|
412
397
|
* Merge the browser-bridge entry into an existing client config file, or
|
|
413
398
|
* create it if it does not exist. Existing unrelated entries are preserved.
|
|
@@ -446,7 +431,7 @@ export async function findConfiguredMcpClients(options) {
|
|
|
446
431
|
for (const configPath of configPaths) {
|
|
447
432
|
try {
|
|
448
433
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
|
449
|
-
if (parseInstalledMcpConfig(clientName, raw).configured) {
|
|
434
|
+
if (parseInstalledMcpConfig(clientName, raw, configPath).configured) {
|
|
450
435
|
configured = true;
|
|
451
436
|
break;
|
|
452
437
|
}
|
|
@@ -499,8 +484,6 @@ export async function removeMcpConfig(clientName, options) {
|
|
|
499
484
|
* @returns {Promise<void>}
|
|
500
485
|
*/
|
|
501
486
|
async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
502
|
-
const newEntry = buildMcpConfig(clientName);
|
|
503
|
-
|
|
504
487
|
/** @type {Record<string, unknown>} */
|
|
505
488
|
let existing = {};
|
|
506
489
|
try {
|
|
@@ -518,21 +501,23 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
518
501
|
// File missing - start fresh.
|
|
519
502
|
}
|
|
520
503
|
|
|
521
|
-
const shape =
|
|
504
|
+
const shape = getMcpConfigShapeForPath(clientName, configPath);
|
|
522
505
|
const topKey = shape.key;
|
|
523
|
-
const
|
|
524
|
-
const
|
|
506
|
+
const serverConfig = createBaseServerConfig(clientName);
|
|
507
|
+
const entry = shape.includeType ? { type: 'stdio', ...serverConfig } : serverConfig;
|
|
508
|
+
const currentBlock = existing[topKey];
|
|
509
|
+
const existingBlock =
|
|
510
|
+
currentBlock && typeof currentBlock === 'object' && !Array.isArray(currentBlock)
|
|
511
|
+
? /** @type {Record<string, unknown>} */ (currentBlock)
|
|
512
|
+
: {};
|
|
525
513
|
|
|
526
514
|
const merged = {
|
|
527
515
|
...existing,
|
|
528
516
|
[topKey]: {
|
|
529
517
|
...existingBlock,
|
|
530
|
-
|
|
518
|
+
[BROWSER_BRIDGE_SERVER_NAME]: entry,
|
|
531
519
|
},
|
|
532
520
|
};
|
|
533
|
-
for (const key of shape.legacyKeys ?? []) {
|
|
534
|
-
delete merged[key];
|
|
535
|
-
}
|
|
536
521
|
|
|
537
522
|
await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
|
|
538
523
|
await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
@@ -579,9 +564,13 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
579
564
|
return false;
|
|
580
565
|
}
|
|
581
566
|
|
|
582
|
-
const shape =
|
|
567
|
+
const shape = getMcpConfigShapeForPath(clientName, configPath);
|
|
583
568
|
const topKey = shape.key;
|
|
584
|
-
const
|
|
569
|
+
const currentBlock = existing[topKey];
|
|
570
|
+
const block =
|
|
571
|
+
currentBlock && typeof currentBlock === 'object' && !Array.isArray(currentBlock)
|
|
572
|
+
? /** @type {Record<string, unknown>} */ (currentBlock)
|
|
573
|
+
: {};
|
|
585
574
|
if (!Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)) {
|
|
586
575
|
return false;
|
|
587
576
|
}
|
|
@@ -590,9 +579,7 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
590
579
|
delete updatedBlock[BROWSER_BRIDGE_SERVER_NAME];
|
|
591
580
|
|
|
592
581
|
const merged = { ...existing };
|
|
593
|
-
|
|
594
|
-
delete merged[key];
|
|
595
|
-
}
|
|
582
|
+
delete merged[topKey];
|
|
596
583
|
if (Object.keys(updatedBlock).length > 0 || shape.keepEmptyBlock) {
|
|
597
584
|
merged[topKey] = updatedBlock;
|
|
598
585
|
}
|
|
@@ -630,23 +617,35 @@ async function removeCodexMcpConfig(configPath, stdout) {
|
|
|
630
617
|
/**
|
|
631
618
|
* @param {McpClientName} clientName
|
|
632
619
|
* @param {string} raw
|
|
620
|
+
* @param {string} [configPath]
|
|
633
621
|
* @returns {{ configured: boolean }}
|
|
634
622
|
*/
|
|
635
|
-
export function parseInstalledMcpConfig(clientName, raw) {
|
|
623
|
+
export function parseInstalledMcpConfig(clientName, raw, configPath) {
|
|
636
624
|
if (clientName === 'codex') {
|
|
637
625
|
return { configured: hasCodexServerBlock(raw) };
|
|
638
626
|
}
|
|
639
627
|
|
|
640
628
|
try {
|
|
641
629
|
const parsed = JSON.parse(raw);
|
|
642
|
-
const
|
|
630
|
+
const config =
|
|
643
631
|
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
644
|
-
?
|
|
632
|
+
? /** @type {Record<string, unknown>} */ (parsed)
|
|
645
633
|
: {};
|
|
634
|
+
const keys = configPath
|
|
635
|
+
? [getMcpConfigShapeForPath(clientName, configPath).key]
|
|
636
|
+
: clientName === 'copilot'
|
|
637
|
+
? ['mcpServers', 'servers']
|
|
638
|
+
: [getMcpConfigShape(clientName).key];
|
|
646
639
|
return {
|
|
647
|
-
configured:
|
|
648
|
-
|
|
649
|
-
|
|
640
|
+
configured: keys.some((key) => {
|
|
641
|
+
const block = config[key];
|
|
642
|
+
return Boolean(
|
|
643
|
+
block &&
|
|
644
|
+
typeof block === 'object' &&
|
|
645
|
+
!Array.isArray(block) &&
|
|
646
|
+
Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)
|
|
647
|
+
);
|
|
648
|
+
}),
|
|
650
649
|
};
|
|
651
650
|
} catch {
|
|
652
651
|
return { configured: false };
|
|
@@ -18,6 +18,12 @@ import {
|
|
|
18
18
|
summarizeDaemonRestarts,
|
|
19
19
|
} from '../../native-host/src/daemon-process.js';
|
|
20
20
|
import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
|
|
21
|
+
import {
|
|
22
|
+
CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
|
|
23
|
+
DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
24
|
+
getBridgeOperationTimeoutMs,
|
|
25
|
+
MAX_CLIENT_REQUEST_TIMEOUT_MS,
|
|
26
|
+
} from '../../protocol/src/index.js';
|
|
21
27
|
import { methodNeedsTab } from './cli-helpers.js';
|
|
22
28
|
import { BridgeClient } from './client.js';
|
|
23
29
|
|
|
@@ -53,11 +59,24 @@ export async function ensureClientConnected(client) {
|
|
|
53
59
|
*/
|
|
54
60
|
export async function requestBridge(client, method, params = {}, options = {}) {
|
|
55
61
|
await ensureClientConnected(client);
|
|
62
|
+
const operationTimeoutMs = getBridgeOperationTimeoutMs(method, params);
|
|
63
|
+
const clientDefaultTimeoutMs =
|
|
64
|
+
typeof client.defaultTimeoutMs === 'number'
|
|
65
|
+
? client.defaultTimeoutMs
|
|
66
|
+
: DEFAULT_CLIENT_REQUEST_TIMEOUT_MS;
|
|
67
|
+
const timeoutMs =
|
|
68
|
+
operationTimeoutMs === null
|
|
69
|
+
? undefined
|
|
70
|
+
: Math.min(
|
|
71
|
+
MAX_CLIENT_REQUEST_TIMEOUT_MS,
|
|
72
|
+
Math.max(clientDefaultTimeoutMs, operationTimeoutMs + CLIENT_REQUEST_TIMEOUT_MARGIN_MS)
|
|
73
|
+
);
|
|
56
74
|
return client.request({
|
|
57
75
|
method,
|
|
58
76
|
params,
|
|
59
77
|
tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
|
|
60
78
|
meta: withRequestMeta(options.source, options.tokenBudget),
|
|
79
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
61
80
|
});
|
|
62
81
|
}
|
|
63
82
|
|
|
@@ -10,9 +10,9 @@ 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, layout.get_box_model
|
|
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.',
|
|
14
14
|
'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
|
|
15
|
-
'Escalate to screenshot.capture_element, screenshot.capture_region,
|
|
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.',
|
|
17
17
|
'Only use the specialized Browser Bridge MCP tools directly when the host has already allowed them or the user explicitly wants typed tool calls.',
|
|
18
18
|
].join('\n');
|
|
@@ -102,8 +102,8 @@ function createGuidePrompt() {
|
|
|
102
102
|
'1. Prefer MCP over `bbx`; do not shell out unless setup, doctor, logs, or raw CLI debugging is explicitly needed.',
|
|
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
|
-
'4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, `styles.get_computed
|
|
106
|
-
'5.
|
|
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, not `budgetPreset`; 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,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
createToolResult,
|
|
5
5
|
getToolTokenBudget,
|
|
6
6
|
REQUEST_SOURCE,
|
|
7
7
|
requestBridgeWithRetry,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './handlers-utils.js';
|
|
14
14
|
|
|
15
15
|
/** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
|
|
16
|
+
/** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
|
|
16
17
|
/** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
|
|
17
18
|
/** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
|
|
18
19
|
|
|
@@ -97,7 +98,170 @@ export async function handleCaptureTool(args) {
|
|
|
97
98
|
return summarizeToolError('nodeId must be a finite number.');
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
const entry = CAPTURE_ACTIONS[args.action];
|
|
102
|
+
if (!entry) {
|
|
103
|
+
return summarizeToolError(`Unsupported capture action "${args.action}".`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return withToolClient(
|
|
107
|
+
async (client) => {
|
|
108
|
+
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
109
|
+
const ref = entry.ref ? await resolveToolRef(client, args, requestedTabId) : undefined;
|
|
110
|
+
const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
|
|
111
|
+
tabId: requestedTabId,
|
|
112
|
+
source: REQUEST_SOURCE,
|
|
113
|
+
tokenBudget: getToolTokenBudget(args),
|
|
114
|
+
});
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
return summarizeToolResponse(response, entry.method);
|
|
117
|
+
}
|
|
118
|
+
return entry.method.startsWith('screenshot.')
|
|
119
|
+
? createScreenshotResult(response, entry.method)
|
|
120
|
+
: createCdpCaptureResult(response, entry.method);
|
|
121
|
+
},
|
|
122
|
+
{ destinationId: args.destinationId ?? null }
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {BridgeResponse & { ok: true }} response
|
|
128
|
+
* @param {BridgeMethod} method
|
|
129
|
+
* @returns {ToolResult}
|
|
130
|
+
*/
|
|
131
|
+
function createScreenshotResult(response, method) {
|
|
132
|
+
const result = toRecord(response.result);
|
|
133
|
+
if (typeof result.image !== 'string') {
|
|
134
|
+
return summarizeToolError(`${method} returned no image data.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const image = normalizeBase64Image(result.image);
|
|
138
|
+
if (!image) {
|
|
139
|
+
return summarizeToolError(`${method} returned invalid base64 image data.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rect = boundedRect(result.rect);
|
|
143
|
+
return createToolResult(
|
|
144
|
+
`Captured ${image.mimeType} screenshot (${image.byteLength} bytes).`,
|
|
145
|
+
{
|
|
146
|
+
ok: true,
|
|
147
|
+
method,
|
|
148
|
+
mimeType: image.mimeType,
|
|
149
|
+
byteLength: image.byteLength,
|
|
150
|
+
...(rect ? { rect } : {}),
|
|
151
|
+
},
|
|
152
|
+
false,
|
|
153
|
+
[{ type: 'image', data: image.data, mimeType: image.mimeType }]
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {BridgeResponse & { ok: true }} response
|
|
159
|
+
* @param {BridgeMethod} method
|
|
160
|
+
* @returns {ToolResult}
|
|
161
|
+
*/
|
|
162
|
+
function createCdpCaptureResult(response, method) {
|
|
163
|
+
const bounded = boundStructuredValue(response.result);
|
|
164
|
+
return createToolResult(`Captured bounded structured data from ${method}.`, {
|
|
165
|
+
ok: true,
|
|
166
|
+
method,
|
|
167
|
+
data: bounded.value,
|
|
168
|
+
truncated: bounded.truncated,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} value
|
|
174
|
+
* @returns {{ data: string, mimeType: string, byteLength: number } | null}
|
|
175
|
+
*/
|
|
176
|
+
function normalizeBase64Image(value) {
|
|
177
|
+
const dataUrl = /^data:([^;,]+);base64,(.*)$/su.exec(value);
|
|
178
|
+
const mimeType = dataUrl?.[1] ?? 'image/png';
|
|
179
|
+
if (!mimeType.startsWith('image/')) return null;
|
|
180
|
+
const data = (dataUrl?.[2] ?? value).replace(/\s+/gu, '');
|
|
181
|
+
if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const bytes = Buffer.from(data, 'base64');
|
|
185
|
+
if (bytes.toString('base64') !== data) return null;
|
|
186
|
+
return { data, mimeType, byteLength: bytes.length };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {unknown} value
|
|
191
|
+
* @returns {Record<string, number> | null}
|
|
192
|
+
*/
|
|
193
|
+
function boundedRect(value) {
|
|
194
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
195
|
+
const source = /** @type {Record<string, unknown>} */ (value);
|
|
196
|
+
/** @type {Record<string, number>} */
|
|
197
|
+
const rect = {};
|
|
198
|
+
for (const key of ['x', 'y', 'width', 'height', 'scale']) {
|
|
199
|
+
if (isFiniteNumber(source[key])) rect[key] = source[key];
|
|
200
|
+
}
|
|
201
|
+
return Object.keys(rect).length > 0 ? rect : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Bound debugger payloads by depth, entries, and string length while retaining
|
|
206
|
+
* their actual values rather than reducing them to field names.
|
|
207
|
+
*
|
|
208
|
+
* @param {unknown} input
|
|
209
|
+
* @returns {{ value: unknown, truncated: boolean }}
|
|
210
|
+
*/
|
|
211
|
+
function boundStructuredValue(input) {
|
|
212
|
+
const state = { entries: 0, characters: 0, truncated: false };
|
|
213
|
+
const maxEntries = 500;
|
|
214
|
+
const maxCharacters = 50_000;
|
|
215
|
+
/** @type {(value: unknown, depth: number) => unknown} */
|
|
216
|
+
const visit = (value, depth) => {
|
|
217
|
+
if (state.entries >= maxEntries || state.characters >= maxCharacters || depth > 8) {
|
|
218
|
+
state.truncated = true;
|
|
219
|
+
return '[truncated]';
|
|
220
|
+
}
|
|
221
|
+
state.entries += 1;
|
|
222
|
+
if (typeof value === 'string') {
|
|
223
|
+
const remaining = maxCharacters - state.characters;
|
|
224
|
+
const limit = Math.min(value.length, remaining, 4000);
|
|
225
|
+
state.characters += limit;
|
|
226
|
+
if (limit === value.length) return value;
|
|
227
|
+
state.truncated = true;
|
|
228
|
+
return `${value.slice(0, limit)}[truncated]`;
|
|
229
|
+
}
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
const remaining = maxEntries - state.entries;
|
|
232
|
+
const limit = Math.min(value.length, remaining, 100);
|
|
233
|
+
if (limit < value.length) state.truncated = true;
|
|
234
|
+
return value.slice(0, limit).map((entry) => visit(entry, depth + 1));
|
|
235
|
+
}
|
|
236
|
+
if (value && typeof value === 'object') {
|
|
237
|
+
/** @type {Record<string, unknown>} */
|
|
238
|
+
const output = {};
|
|
239
|
+
const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
|
|
240
|
+
const limit = Math.min(entries.length, 100);
|
|
241
|
+
if (limit < entries.length) state.truncated = true;
|
|
242
|
+
for (const [key, entry] of entries.slice(0, limit)) {
|
|
243
|
+
if (state.entries >= maxEntries || state.characters >= maxCharacters) {
|
|
244
|
+
state.truncated = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
state.characters += key.length;
|
|
248
|
+
output[key] = visit(entry, depth + 1);
|
|
249
|
+
}
|
|
250
|
+
return output;
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
};
|
|
254
|
+
return { value: visit(input, 0), truncated: state.truncated };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {unknown} value
|
|
259
|
+
* @returns {Record<string, unknown>}
|
|
260
|
+
*/
|
|
261
|
+
function toRecord(value) {
|
|
262
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
263
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
264
|
+
: {};
|
|
101
265
|
}
|
|
102
266
|
|
|
103
267
|
/** @type {Record<string, BridgeMethod>} */
|