@browserbridge/bbx 1.7.2 → 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 +55 -55
- 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
|
|
|
@@ -75,7 +75,7 @@ function getLegacyCopilotVsCodeConfigPath() {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* @param {McpClientName} clientName
|
|
78
|
-
* @returns {{ key: string, includeType: boolean,
|
|
78
|
+
* @returns {{ key: string, includeType: boolean, keepEmptyBlock?: boolean }}
|
|
79
79
|
*/
|
|
80
80
|
export function getMcpConfigShape(clientName) {
|
|
81
81
|
return MCP_CONFIG_SHAPES[clientName] ?? { key: 'mcpServers', includeType: false };
|
|
@@ -108,7 +108,7 @@ function createBaseServerConfig(clientName) {
|
|
|
108
108
|
return serverConfig;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** @type {Record<McpClientName, { key: string, includeType: boolean,
|
|
111
|
+
/** @type {Record<McpClientName, { key: string, includeType: boolean, keepEmptyBlock?: boolean }>} */
|
|
112
112
|
const MCP_CONFIG_SHAPES = {
|
|
113
113
|
antigravity: { key: 'mcpServers', includeType: false },
|
|
114
114
|
agents: { key: 'mcpServers', includeType: true },
|
|
@@ -116,7 +116,6 @@ const MCP_CONFIG_SHAPES = {
|
|
|
116
116
|
copilot: {
|
|
117
117
|
key: 'mcpServers',
|
|
118
118
|
includeType: true,
|
|
119
|
-
legacyKeys: ['servers'],
|
|
120
119
|
keepEmptyBlock: true,
|
|
121
120
|
},
|
|
122
121
|
cursor: { key: 'mcpServers', includeType: false },
|
|
@@ -125,6 +124,24 @@ const MCP_CONFIG_SHAPES = {
|
|
|
125
124
|
opencode: { key: 'mcp', includeType: false },
|
|
126
125
|
};
|
|
127
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
|
+
|
|
128
145
|
/**
|
|
129
146
|
* @param {McpClientName} clientName
|
|
130
147
|
* @returns {Record<string, unknown>}
|
|
@@ -376,37 +393,6 @@ function removeCodexServerBlock(raw) {
|
|
|
376
393
|
return trimmed ? `${trimmed}\n` : '';
|
|
377
394
|
}
|
|
378
395
|
|
|
379
|
-
/**
|
|
380
|
-
* @param {McpClientName} clientName
|
|
381
|
-
* @returns {string[]}
|
|
382
|
-
*/
|
|
383
|
-
function getJsonMcpBlockKeys(clientName) {
|
|
384
|
-
const shape = getMcpConfigShape(clientName);
|
|
385
|
-
return [...(shape.legacyKeys ?? []), shape.key];
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Merge MCP entries from the current config key plus any legacy aliases.
|
|
390
|
-
*
|
|
391
|
-
* @param {Record<string, unknown>} config
|
|
392
|
-
* @param {McpClientName} clientName
|
|
393
|
-
* @returns {Record<string, unknown>}
|
|
394
|
-
*/
|
|
395
|
-
function getMergedJsonMcpBlock(config, clientName) {
|
|
396
|
-
/** @type {Record<string, unknown>} */
|
|
397
|
-
const merged = {};
|
|
398
|
-
|
|
399
|
-
for (const key of getJsonMcpBlockKeys(clientName)) {
|
|
400
|
-
const block = config[key];
|
|
401
|
-
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
Object.assign(merged, /** @type {Record<string, unknown>} */ (block));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return merged;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
396
|
/**
|
|
411
397
|
* Merge the browser-bridge entry into an existing client config file, or
|
|
412
398
|
* create it if it does not exist. Existing unrelated entries are preserved.
|
|
@@ -445,7 +431,7 @@ export async function findConfiguredMcpClients(options) {
|
|
|
445
431
|
for (const configPath of configPaths) {
|
|
446
432
|
try {
|
|
447
433
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
|
448
|
-
if (parseInstalledMcpConfig(clientName, raw).configured) {
|
|
434
|
+
if (parseInstalledMcpConfig(clientName, raw, configPath).configured) {
|
|
449
435
|
configured = true;
|
|
450
436
|
break;
|
|
451
437
|
}
|
|
@@ -498,8 +484,6 @@ export async function removeMcpConfig(clientName, options) {
|
|
|
498
484
|
* @returns {Promise<void>}
|
|
499
485
|
*/
|
|
500
486
|
async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
501
|
-
const newEntry = buildMcpConfig(clientName);
|
|
502
|
-
|
|
503
487
|
/** @type {Record<string, unknown>} */
|
|
504
488
|
let existing = {};
|
|
505
489
|
try {
|
|
@@ -517,21 +501,23 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
517
501
|
// File missing - start fresh.
|
|
518
502
|
}
|
|
519
503
|
|
|
520
|
-
const shape =
|
|
504
|
+
const shape = getMcpConfigShapeForPath(clientName, configPath);
|
|
521
505
|
const topKey = shape.key;
|
|
522
|
-
const
|
|
523
|
-
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
|
+
: {};
|
|
524
513
|
|
|
525
514
|
const merged = {
|
|
526
515
|
...existing,
|
|
527
516
|
[topKey]: {
|
|
528
517
|
...existingBlock,
|
|
529
|
-
|
|
518
|
+
[BROWSER_BRIDGE_SERVER_NAME]: entry,
|
|
530
519
|
},
|
|
531
520
|
};
|
|
532
|
-
for (const key of shape.legacyKeys ?? []) {
|
|
533
|
-
delete merged[key];
|
|
534
|
-
}
|
|
535
521
|
|
|
536
522
|
await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
|
|
537
523
|
await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
@@ -578,9 +564,13 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
578
564
|
return false;
|
|
579
565
|
}
|
|
580
566
|
|
|
581
|
-
const shape =
|
|
567
|
+
const shape = getMcpConfigShapeForPath(clientName, configPath);
|
|
582
568
|
const topKey = shape.key;
|
|
583
|
-
const
|
|
569
|
+
const currentBlock = existing[topKey];
|
|
570
|
+
const block =
|
|
571
|
+
currentBlock && typeof currentBlock === 'object' && !Array.isArray(currentBlock)
|
|
572
|
+
? /** @type {Record<string, unknown>} */ (currentBlock)
|
|
573
|
+
: {};
|
|
584
574
|
if (!Object.hasOwn(block, BROWSER_BRIDGE_SERVER_NAME)) {
|
|
585
575
|
return false;
|
|
586
576
|
}
|
|
@@ -589,9 +579,7 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
|
|
|
589
579
|
delete updatedBlock[BROWSER_BRIDGE_SERVER_NAME];
|
|
590
580
|
|
|
591
581
|
const merged = { ...existing };
|
|
592
|
-
|
|
593
|
-
delete merged[key];
|
|
594
|
-
}
|
|
582
|
+
delete merged[topKey];
|
|
595
583
|
if (Object.keys(updatedBlock).length > 0 || shape.keepEmptyBlock) {
|
|
596
584
|
merged[topKey] = updatedBlock;
|
|
597
585
|
}
|
|
@@ -629,23 +617,35 @@ async function removeCodexMcpConfig(configPath, stdout) {
|
|
|
629
617
|
/**
|
|
630
618
|
* @param {McpClientName} clientName
|
|
631
619
|
* @param {string} raw
|
|
620
|
+
* @param {string} [configPath]
|
|
632
621
|
* @returns {{ configured: boolean }}
|
|
633
622
|
*/
|
|
634
|
-
export function parseInstalledMcpConfig(clientName, raw) {
|
|
623
|
+
export function parseInstalledMcpConfig(clientName, raw, configPath) {
|
|
635
624
|
if (clientName === 'codex') {
|
|
636
625
|
return { configured: hasCodexServerBlock(raw) };
|
|
637
626
|
}
|
|
638
627
|
|
|
639
628
|
try {
|
|
640
629
|
const parsed = JSON.parse(raw);
|
|
641
|
-
const
|
|
630
|
+
const config =
|
|
642
631
|
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
643
|
-
?
|
|
632
|
+
? /** @type {Record<string, unknown>} */ (parsed)
|
|
644
633
|
: {};
|
|
634
|
+
const keys = configPath
|
|
635
|
+
? [getMcpConfigShapeForPath(clientName, configPath).key]
|
|
636
|
+
: clientName === 'copilot'
|
|
637
|
+
? ['mcpServers', 'servers']
|
|
638
|
+
: [getMcpConfigShape(clientName).key];
|
|
645
639
|
return {
|
|
646
|
-
configured:
|
|
647
|
-
|
|
648
|
-
|
|
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
|
+
}),
|
|
649
649
|
};
|
|
650
650
|
} catch {
|
|
651
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>} */
|