@browserbridge/bbx 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +8 -10
- package/packages/agent-client/src/cli-args.js +35 -6
- package/packages/agent-client/src/cli-batch.js +10 -5
- package/packages/agent-client/src/cli-helpers.js +8 -0
- package/packages/agent-client/src/cli-proxy-remote.js +7 -4
- package/packages/agent-client/src/cli-setup-commands.js +3 -1
- package/packages/agent-client/src/cli.js +35 -15
- package/packages/agent-client/src/command-registry.js +7 -3
- package/packages/agent-client/src/mcp-config.js +8 -8
- package/packages/agent-client/src/npm-self-update.js +9 -2
- package/packages/agent-client/src/runtime.js +14 -5
- package/packages/agent-client/src/types.ts +2 -0
- package/packages/mcp-server/src/bin.js +1 -1
- package/packages/mcp-server/src/guidance.js +43 -6
- package/packages/mcp-server/src/handlers-capture.js +1 -1
- package/packages/mcp-server/src/handlers-page.js +162 -9
- package/packages/mcp-server/src/handlers-utils.js +145 -169
- package/packages/mcp-server/src/handlers.js +17 -5
- package/packages/mcp-server/src/server.js +255 -91
- package/packages/mcp-server/src/toolset.js +73 -0
- package/packages/native-host/src/daemon.js +24 -107
- package/packages/native-host/src/dom-baseline-owners.js +210 -0
- package/packages/native-host/src/native-host.js +14 -28
- package/packages/protocol/src/budget-presets.js +182 -0
- package/packages/protocol/src/har.js +34 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/json-lines.js +3 -2
- package/packages/protocol/src/protocol.js +10 -0
- package/packages/protocol/src/registry.js +83 -0
- package/packages/protocol/src/types.ts +3 -0
- package/skills/browser-bridge/SKILL.md +4 -0
- package/skills/browser-bridge/references/protocol.md +11 -5
|
@@ -1,17 +1,54 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Guidance lines shared by the progressive tool surface.
|
|
5
|
+
*
|
|
6
|
+
* @type {readonly string[]}
|
|
7
|
+
*/
|
|
8
|
+
const SHARED_INSTRUCTIONS = Object.freeze([
|
|
6
9
|
'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
|
|
7
10
|
'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.',
|
|
8
11
|
'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, page.extract_content, styles.get_computed, or layout.get_box_model. Prefer semantic extraction for articles and documentation. Use browser_batch only for parallel reads; run mutations sequentially and keep limits tight before widening.',
|
|
9
|
-
'Reuse elementRef values returned by DOM
|
|
12
|
+
'Reuse elementRef values returned by DOM reads. Use attribute allowlists for focused DOM reads.',
|
|
10
13
|
'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.',
|
|
11
14
|
'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
|
|
12
|
-
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Workflow guidance appended after the tool-selection rules.
|
|
19
|
+
*
|
|
20
|
+
* @type {readonly string[]}
|
|
21
|
+
*/
|
|
22
|
+
const WORKFLOW_INSTRUCTIONS = Object.freeze([
|
|
13
23
|
'',
|
|
14
24
|
'Page investigation: read page state, scoped DOM, and relevant page text first, batching independent reads. Find targets by text or role when selectors are unknown. Add styles, console, or network reads only when they directly answer the objective, and use screenshots or evaluation only when structured evidence is insufficient.',
|
|
15
25
|
'Layout debugging: locate the target, read only relevant computed properties and its box model, and inspect matched rules only when the cascade is unclear. Prototype the smallest fix with a reversible style patch, verify the result, check for new console errors, then rollback before editing source unless the user asks to keep the patch.',
|
|
16
26
|
'Flow verification: read initial page state, locate controls semantically, reuse elementRef values, interact through input tools, and wait for navigation or UI state changes. Verify the final DOM or page text plus console and network state when relevant. Do not create a new tab unless requested or required by the flow.',
|
|
17
|
-
]
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SERVER_HEADER =
|
|
30
|
+
"Browser Bridge MCP inspects and interacts with the user's real Chrome tab through a compact tool set.";
|
|
31
|
+
|
|
32
|
+
const LEGACY_TOOL_RULES = Object.freeze([
|
|
33
|
+
'The common tools are available immediately. When a specialized typed tool is useful, call browser_toolset with its exact tool name; browser_call always reaches every bridge method without loading another tool.',
|
|
34
|
+
'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const MODERN_TOOL_RULES = Object.freeze([
|
|
38
|
+
'The compact tool list is static for stateless MCP. browser_call reaches every bridge method, and browser_skill returns runtime groups and limits.',
|
|
39
|
+
'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build instructions for the negotiated MCP era.
|
|
44
|
+
*
|
|
45
|
+
* @param {'legacy' | 'modern'} [era='legacy']
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function getMcpServerInstructions(era = 'legacy') {
|
|
49
|
+
const toolRules = era === 'modern' ? MODERN_TOOL_RULES : LEGACY_TOOL_RULES;
|
|
50
|
+
return [SERVER_HEADER, ...toolRules, ...SHARED_INSTRUCTIONS, ...WORKFLOW_INSTRUCTIONS].join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions();
|
|
54
|
+
export const MODERN_MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions('modern');
|
|
@@ -153,7 +153,7 @@ export function createScreenshotResult(response, method) {
|
|
|
153
153
|
return summarizeToolError(new Error(`${method} returned invalid artifact metadata.`));
|
|
154
154
|
}
|
|
155
155
|
return createToolResult(
|
|
156
|
-
`Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}).`,
|
|
156
|
+
`Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}). Read it in chunks with the browser_artifact tool (action "read", paging offset until nextOffset is null), then delete it with browser_artifact action "delete" when done.`,
|
|
157
157
|
{
|
|
158
158
|
ok: true,
|
|
159
159
|
method,
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
DEFAULT_CONSOLE_LIMIT,
|
|
5
5
|
DEFAULT_HAR_LIMIT,
|
|
6
6
|
DEFAULT_NETWORK_LIMIT,
|
|
7
|
+
describeBridgeMethods,
|
|
7
8
|
ERROR_CODES,
|
|
8
9
|
getErrorRecovery,
|
|
9
10
|
isBatchSafeBridgeCall,
|
|
11
|
+
isHarDocument,
|
|
10
12
|
MAX_BATCH_CALLS,
|
|
11
13
|
MAX_BATCH_CONCURRENCY,
|
|
12
14
|
METHOD_SET,
|
|
@@ -166,6 +168,157 @@ export async function handlePageTool(args) {
|
|
|
166
168
|
});
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Read one chunk of a daemon-owned artifact or delete it. Reads are paged by
|
|
173
|
+
* the caller: pass the returned nextOffset until it comes back null, then
|
|
174
|
+
* verify the reassembled bytes against sha256.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ action: string, artifactId?: string, offset?: number, limit?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
177
|
+
* @returns {Promise<ToolResult>}
|
|
178
|
+
*/
|
|
179
|
+
export async function handleArtifactTool(args) {
|
|
180
|
+
if (args.action !== 'read' && args.action !== 'delete') {
|
|
181
|
+
return summarizeToolError(`Unsupported artifact action "${args.action}".`);
|
|
182
|
+
}
|
|
183
|
+
if (typeof args.artifactId !== 'string' || !args.artifactId.trim()) {
|
|
184
|
+
return summarizeToolError('artifactId is required.');
|
|
185
|
+
}
|
|
186
|
+
if (args.offset !== undefined && (!Number.isInteger(args.offset) || args.offset < 0)) {
|
|
187
|
+
return summarizeToolError('offset must be a non-negative integer.');
|
|
188
|
+
}
|
|
189
|
+
if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1)) {
|
|
190
|
+
return summarizeToolError('limit must be a positive integer.');
|
|
191
|
+
}
|
|
192
|
+
const method = /** @type {BridgeMethod} */ (
|
|
193
|
+
args.action === 'read' ? 'artifact.read' : 'artifact.delete'
|
|
194
|
+
);
|
|
195
|
+
const params =
|
|
196
|
+
args.action === 'read'
|
|
197
|
+
? {
|
|
198
|
+
artifactId: args.artifactId,
|
|
199
|
+
offset: args.offset ?? 0,
|
|
200
|
+
...(typeof args.limit === 'number' ? { maxBytes: args.limit } : {}),
|
|
201
|
+
}
|
|
202
|
+
: { artifactId: args.artifactId };
|
|
203
|
+
return withToolClient(
|
|
204
|
+
async (client) => {
|
|
205
|
+
const response = await requestBridgeWithRetry(client, method, params, {
|
|
206
|
+
tabId: null,
|
|
207
|
+
source: REQUEST_SOURCE,
|
|
208
|
+
tokenBudget: getToolTokenBudget(args),
|
|
209
|
+
});
|
|
210
|
+
return method === 'artifact.read'
|
|
211
|
+
? createArtifactReadResult(response)
|
|
212
|
+
: summarizeToolResponse(response, method, params);
|
|
213
|
+
},
|
|
214
|
+
{ destinationId: args.destinationId ?? null }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Manage CDP Fetch request interception rules for a tab.
|
|
220
|
+
*
|
|
221
|
+
* @param {{ action: string, urlPattern?: string, ruleAction?: 'block' | 'fulfill' | 'continue', statusCode?: number, body?: string, headers?: Record<string, string>, ruleId?: string, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
222
|
+
* @returns {Promise<ToolResult>}
|
|
223
|
+
*/
|
|
224
|
+
export async function handleInterceptTool(args) {
|
|
225
|
+
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
226
|
+
return withToolClient(
|
|
227
|
+
async (client) => {
|
|
228
|
+
/** @type {BridgeMethod} */
|
|
229
|
+
let method;
|
|
230
|
+
/** @type {Record<string, unknown>} */
|
|
231
|
+
let params;
|
|
232
|
+
switch (args.action) {
|
|
233
|
+
case 'add': {
|
|
234
|
+
if (typeof args.urlPattern !== 'string' || !args.urlPattern.trim()) {
|
|
235
|
+
return summarizeToolError('urlPattern is required for intercept add.');
|
|
236
|
+
}
|
|
237
|
+
method = 'network.intercept.add';
|
|
238
|
+
params = {
|
|
239
|
+
urlPattern: args.urlPattern,
|
|
240
|
+
...(args.ruleAction ? { action: args.ruleAction } : {}),
|
|
241
|
+
...(typeof args.statusCode === 'number' ? { statusCode: args.statusCode } : {}),
|
|
242
|
+
...(typeof args.body === 'string' ? { body: args.body } : {}),
|
|
243
|
+
...(args.headers ? { headers: args.headers } : {}),
|
|
244
|
+
};
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'remove': {
|
|
248
|
+
if (typeof args.ruleId !== 'string' || !args.ruleId.trim()) {
|
|
249
|
+
return summarizeToolError('ruleId is required for intercept remove.');
|
|
250
|
+
}
|
|
251
|
+
method = 'network.intercept.remove';
|
|
252
|
+
params = { ruleId: args.ruleId };
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 'list':
|
|
256
|
+
method = 'network.intercept.list';
|
|
257
|
+
params = {};
|
|
258
|
+
break;
|
|
259
|
+
case 'clear':
|
|
260
|
+
method = 'network.intercept.clear';
|
|
261
|
+
params = {};
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
return summarizeToolError(`Unsupported intercept action "${args.action}".`);
|
|
265
|
+
}
|
|
266
|
+
const response = await requestBridgeWithRetry(client, method, params, {
|
|
267
|
+
tabId: requestedTabId,
|
|
268
|
+
source: REQUEST_SOURCE,
|
|
269
|
+
tokenBudget: getToolTokenBudget(args),
|
|
270
|
+
});
|
|
271
|
+
return summarizeToolResponse(response, method, params);
|
|
272
|
+
},
|
|
273
|
+
{ destinationId: args.destinationId ?? null }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {import('../../protocol/src/types.js').BridgeResponse} response
|
|
279
|
+
* @returns {ToolResult}
|
|
280
|
+
*/
|
|
281
|
+
function createArtifactReadResult(response) {
|
|
282
|
+
if (!response.ok) return summarizeToolResponse(response, 'artifact.read', {});
|
|
283
|
+
const result =
|
|
284
|
+
response.result && typeof response.result === 'object' && !Array.isArray(response.result)
|
|
285
|
+
? /** @type {Record<string, unknown>} */ (response.result)
|
|
286
|
+
: {};
|
|
287
|
+
if (
|
|
288
|
+
typeof result.artifactId !== 'string' ||
|
|
289
|
+
typeof result.data !== 'string' ||
|
|
290
|
+
typeof result.offset !== 'number' ||
|
|
291
|
+
typeof result.byteLength !== 'number' ||
|
|
292
|
+
typeof result.totalBytes !== 'number' ||
|
|
293
|
+
typeof result.sha256 !== 'string' ||
|
|
294
|
+
(result.nextOffset !== null && typeof result.nextOffset !== 'number')
|
|
295
|
+
) {
|
|
296
|
+
return summarizeToolError('artifact.read returned invalid chunk metadata.');
|
|
297
|
+
}
|
|
298
|
+
const nextOffset = /** @type {number | null} */ (result.nextOffset);
|
|
299
|
+
return createToolResult(
|
|
300
|
+
`Read ${result.byteLength} bytes from artifact ${result.artifactId} at offset ${result.offset} (${result.totalBytes} bytes total). ${
|
|
301
|
+
nextOffset === null
|
|
302
|
+
? 'Artifact fully read; verify the reassembled bytes against sha256.'
|
|
303
|
+
: `Continue reading with offset ${nextOffset}.`
|
|
304
|
+
}`,
|
|
305
|
+
{
|
|
306
|
+
ok: true,
|
|
307
|
+
method: 'artifact.read',
|
|
308
|
+
artifactId: result.artifactId,
|
|
309
|
+
data: result.data,
|
|
310
|
+
offset: result.offset,
|
|
311
|
+
byteLength: result.byteLength,
|
|
312
|
+
nextOffset,
|
|
313
|
+
totalBytes: result.totalBytes,
|
|
314
|
+
sha256: result.sha256,
|
|
315
|
+
chunkIndex: result.chunkIndex,
|
|
316
|
+
chunkCount: result.chunkCount,
|
|
317
|
+
expiresAt: result.expiresAt,
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
169
322
|
/**
|
|
170
323
|
* Deliberately read one exact storage value without retries, budgeting, or
|
|
171
324
|
* value-derived delivery metrics.
|
|
@@ -355,6 +508,14 @@ export async function handleRawCallTool(args) {
|
|
|
355
508
|
if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
|
|
356
509
|
return summarizeToolError(`Unknown bridge method "${args.method}".`);
|
|
357
510
|
}
|
|
511
|
+
if (args.method === 'protocol.describe') {
|
|
512
|
+
try {
|
|
513
|
+
const result = describeBridgeMethods(args.params);
|
|
514
|
+
return createToolResult('Bridge protocol description loaded.', result);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return summarizeToolError(error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
358
519
|
return withToolClient(
|
|
359
520
|
async (client) => {
|
|
360
521
|
const method = /** @type {BridgeMethod} */ (args.method);
|
|
@@ -422,15 +583,7 @@ export function createHarExportResult(response) {
|
|
|
422
583
|
}
|
|
423
584
|
|
|
424
585
|
if (result.delivery === 'inline') {
|
|
425
|
-
|
|
426
|
-
result.har && typeof result.har === 'object' && !Array.isArray(result.har)
|
|
427
|
-
? /** @type {Record<string, unknown>} */ (result.har)
|
|
428
|
-
: {};
|
|
429
|
-
const log =
|
|
430
|
-
har.log && typeof har.log === 'object' && !Array.isArray(har.log)
|
|
431
|
-
? /** @type {Record<string, unknown>} */ (har.log)
|
|
432
|
-
: {};
|
|
433
|
-
if (log.version !== '1.2' || !Array.isArray(log.entries)) {
|
|
586
|
+
if (!isHarDocument(result.har)) {
|
|
434
587
|
return summarizeToolError('network.export_har returned an invalid inline HAR document.');
|
|
435
588
|
}
|
|
436
589
|
} else if (result.delivery === 'artifact') {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
BridgeError,
|
|
7
8
|
bridgeMethodNeedsTab,
|
|
8
9
|
DEFAULT_CONSOLE_LIMIT,
|
|
9
|
-
DEFAULT_HAR_LIMIT,
|
|
10
10
|
DEFAULT_LOG_TAIL_LIMIT,
|
|
11
11
|
DEFAULT_MAX_HTML_LENGTH,
|
|
12
12
|
DEFAULT_MAX_NODES,
|
|
@@ -15,11 +15,21 @@ import {
|
|
|
15
15
|
DEFAULT_TEXT_BUDGET,
|
|
16
16
|
estimateJsonPayloadCost,
|
|
17
17
|
getBudgetPreset,
|
|
18
|
+
getBudgetPresetName,
|
|
18
19
|
getErrorRecovery,
|
|
19
|
-
isBudgetPresetName,
|
|
20
20
|
summarizeBatchErrorItem,
|
|
21
21
|
summarizeBatchResponseItem,
|
|
22
22
|
} from '../../protocol/src/index.js';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
applyHtmlBudgetPreset,
|
|
26
|
+
applyLimitBudgetPreset,
|
|
27
|
+
applyMethodBudgetPreset,
|
|
28
|
+
applyPageTextBudgetPreset,
|
|
29
|
+
applyTextBudgetPreset,
|
|
30
|
+
applyTreeBudgetPreset,
|
|
31
|
+
getBudgetPresetName,
|
|
32
|
+
} from '../../protocol/src/index.js';
|
|
23
33
|
import {
|
|
24
34
|
getDoctorReport,
|
|
25
35
|
requestBridge,
|
|
@@ -37,6 +47,18 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
|
|
|
37
47
|
|
|
38
48
|
export const REQUEST_SOURCE = 'mcp';
|
|
39
49
|
const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
|
|
50
|
+
/** @type {AsyncLocalStorage<import('../../protocol/src/types.js').McpProtocolEra>} */
|
|
51
|
+
const MCP_REQUEST_ERA = new AsyncLocalStorage();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @template T
|
|
55
|
+
* @param {import('../../protocol/src/types.js').McpProtocolEra} era
|
|
56
|
+
* @param {() => T} callback
|
|
57
|
+
* @returns {T}
|
|
58
|
+
*/
|
|
59
|
+
export function runWithMcpRequestEra(era, callback) {
|
|
60
|
+
return MCP_REQUEST_ERA.run(era, callback);
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
/** @type {ReadonlySet<BridgeMethod>} */
|
|
42
64
|
const RETRY_SAFE_METHODS = new Set([
|
|
@@ -68,6 +90,8 @@ const RETRY_SAFE_METHODS = new Set([
|
|
|
68
90
|
'screenshot.capture_region',
|
|
69
91
|
'screenshot.capture_element',
|
|
70
92
|
'screenshot.capture_full_page',
|
|
93
|
+
'artifact.read',
|
|
94
|
+
'network.intercept.list',
|
|
71
95
|
'performance.get_metrics',
|
|
72
96
|
'cdp.get_document',
|
|
73
97
|
'cdp.get_dom_snapshot',
|
|
@@ -240,14 +264,6 @@ export async function resolveToolRef(client, input, tabId = null) {
|
|
|
240
264
|
throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
|
|
241
265
|
}
|
|
242
266
|
|
|
243
|
-
/**
|
|
244
|
-
* @param {unknown} value
|
|
245
|
-
* @returns {'quick' | 'normal' | 'deep' | null}
|
|
246
|
-
*/
|
|
247
|
-
export function getBudgetPresetName(value) {
|
|
248
|
-
return isBudgetPresetName(value) ? value : null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
267
|
/**
|
|
252
268
|
* @param {{ budgetPreset?: unknown, selector?: unknown, elementRef?: unknown }} args
|
|
253
269
|
* @returns {'quick' | 'normal' | 'deep' | null}
|
|
@@ -271,162 +287,6 @@ export function getToolTokenBudget(args) {
|
|
|
271
287
|
return presetName ? getBudgetPreset(presetName).tokenBudget : null;
|
|
272
288
|
}
|
|
273
289
|
|
|
274
|
-
/**
|
|
275
|
-
* @template {{ budgetPreset?: unknown, maxNodes?: unknown, maxDepth?: unknown, textBudget?: unknown }} T
|
|
276
|
-
* @param {T} args
|
|
277
|
-
* @returns {T}
|
|
278
|
-
*/
|
|
279
|
-
export function applyTreeBudgetPreset(args) {
|
|
280
|
-
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
281
|
-
if (!presetName) {
|
|
282
|
-
return args;
|
|
283
|
-
}
|
|
284
|
-
const preset = getBudgetPreset(presetName);
|
|
285
|
-
return /** @type {T} */ ({
|
|
286
|
-
...args,
|
|
287
|
-
maxNodes: args.maxNodes ?? preset.maxNodes,
|
|
288
|
-
maxDepth: args.maxDepth ?? preset.maxDepth,
|
|
289
|
-
textBudget: args.textBudget ?? preset.textBudget,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
|
|
295
|
-
* @param {T} args
|
|
296
|
-
* @returns {T}
|
|
297
|
-
*/
|
|
298
|
-
export function applyTextBudgetPreset(args) {
|
|
299
|
-
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
300
|
-
if (!presetName) {
|
|
301
|
-
return args;
|
|
302
|
-
}
|
|
303
|
-
const preset = getBudgetPreset(presetName);
|
|
304
|
-
return /** @type {T} */ ({
|
|
305
|
-
...args,
|
|
306
|
-
textBudget: args.textBudget ?? preset.textBudget,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
|
|
312
|
-
* @param {T} args
|
|
313
|
-
* @returns {T}
|
|
314
|
-
*/
|
|
315
|
-
export function applyPageTextBudgetPreset(args) {
|
|
316
|
-
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
317
|
-
if (!presetName) {
|
|
318
|
-
return args;
|
|
319
|
-
}
|
|
320
|
-
const textBudgetByPreset = {
|
|
321
|
-
quick: 2000,
|
|
322
|
-
normal: DEFAULT_PAGE_TEXT_BUDGET,
|
|
323
|
-
deep: DEFAULT_PAGE_TEXT_BUDGET * 2,
|
|
324
|
-
};
|
|
325
|
-
return /** @type {T} */ ({
|
|
326
|
-
...args,
|
|
327
|
-
textBudget: args.textBudget ?? textBudgetByPreset[presetName],
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* @template {{ budgetPreset?: unknown, limit?: unknown }} T
|
|
333
|
-
* @param {T} args
|
|
334
|
-
* @param {{ quick: number, normal: number, deep: number }} defaults
|
|
335
|
-
* @returns {T}
|
|
336
|
-
*/
|
|
337
|
-
export function applyLimitBudgetPreset(args, defaults) {
|
|
338
|
-
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
339
|
-
if (!presetName) {
|
|
340
|
-
return args;
|
|
341
|
-
}
|
|
342
|
-
return /** @type {T} */ ({
|
|
343
|
-
...args,
|
|
344
|
-
limit: args.limit ?? defaults[presetName],
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* @template {{ budgetPreset?: unknown, maxLength?: unknown }} T
|
|
350
|
-
* @param {T} args
|
|
351
|
-
* @returns {T}
|
|
352
|
-
*/
|
|
353
|
-
export function applyHtmlBudgetPreset(args) {
|
|
354
|
-
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
355
|
-
if (!presetName) {
|
|
356
|
-
return args;
|
|
357
|
-
}
|
|
358
|
-
const maxLengthByPreset = {
|
|
359
|
-
quick: 600,
|
|
360
|
-
normal: DEFAULT_MAX_HTML_LENGTH,
|
|
361
|
-
deep: 6000,
|
|
362
|
-
};
|
|
363
|
-
return /** @type {T} */ ({
|
|
364
|
-
...args,
|
|
365
|
-
maxLength: args.maxLength ?? maxLengthByPreset[presetName],
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Apply a preset to method parameters before dispatch. Explicit method params
|
|
371
|
-
* always win over preset defaults.
|
|
372
|
-
*
|
|
373
|
-
* @param {BridgeMethod} method
|
|
374
|
-
* @param {Record<string, unknown>} params
|
|
375
|
-
* @param {unknown} budgetPreset
|
|
376
|
-
* @returns {Record<string, unknown>}
|
|
377
|
-
*/
|
|
378
|
-
export function applyMethodBudgetPreset(method, params, budgetPreset) {
|
|
379
|
-
const args = { ...params, budgetPreset };
|
|
380
|
-
/** @type {Record<string, unknown>} */
|
|
381
|
-
let normalized = args;
|
|
382
|
-
if (
|
|
383
|
-
method === 'dom.query' ||
|
|
384
|
-
method === 'dom.get_accessibility_tree' ||
|
|
385
|
-
method === 'dom.baseline.create'
|
|
386
|
-
) {
|
|
387
|
-
normalized = applyTreeBudgetPreset(args);
|
|
388
|
-
} else if (method === 'dom.baseline.compare') {
|
|
389
|
-
const preset = getBudgetPresetName(budgetPreset);
|
|
390
|
-
const defaults = { quick: 10, normal: 50, deep: 100 };
|
|
391
|
-
normalized = {
|
|
392
|
-
...args,
|
|
393
|
-
maxChanges: params.maxChanges ?? (preset ? defaults[preset] : undefined),
|
|
394
|
-
};
|
|
395
|
-
} else if (method === 'dom.get_text') {
|
|
396
|
-
normalized = applyTextBudgetPreset(args);
|
|
397
|
-
} else if (method === 'dom.get_html') {
|
|
398
|
-
normalized = applyHtmlBudgetPreset(args);
|
|
399
|
-
} else if (method === 'page.get_text' || method === 'page.extract_content') {
|
|
400
|
-
normalized = applyPageTextBudgetPreset(args);
|
|
401
|
-
} else if (method === 'page.get_console') {
|
|
402
|
-
normalized = applyLimitBudgetPreset(args, {
|
|
403
|
-
quick: 10,
|
|
404
|
-
normal: DEFAULT_CONSOLE_LIMIT,
|
|
405
|
-
deep: 100,
|
|
406
|
-
});
|
|
407
|
-
} else if (method === 'page.get_network') {
|
|
408
|
-
normalized = applyLimitBudgetPreset(args, {
|
|
409
|
-
quick: 10,
|
|
410
|
-
normal: DEFAULT_NETWORK_LIMIT,
|
|
411
|
-
deep: 100,
|
|
412
|
-
});
|
|
413
|
-
} else if (method === 'network.export_har') {
|
|
414
|
-
normalized = applyLimitBudgetPreset(args, {
|
|
415
|
-
quick: 20,
|
|
416
|
-
normal: DEFAULT_HAR_LIMIT,
|
|
417
|
-
deep: 100,
|
|
418
|
-
});
|
|
419
|
-
} else if (method === 'log.tail') {
|
|
420
|
-
normalized = applyLimitBudgetPreset(args, {
|
|
421
|
-
quick: 10,
|
|
422
|
-
normal: DEFAULT_LOG_TAIL_LIMIT,
|
|
423
|
-
deep: 100,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
|
|
427
|
-
return methodParams;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
290
|
/**
|
|
431
291
|
* @param {unknown} input
|
|
432
292
|
* @param {{ maxEntries?: number, maxCharacters?: number, maxStringLength?: number }} [options]
|
|
@@ -586,6 +446,14 @@ function getRequestAwareEvidence(rawResult, method, params, fallback) {
|
|
|
586
446
|
limit: { maxEntries },
|
|
587
447
|
};
|
|
588
448
|
}
|
|
449
|
+
if (method?.startsWith('network.intercept.')) {
|
|
450
|
+
const bounded = boundToolValue(result, { maxEntries: 100, maxCharacters: 10_000 });
|
|
451
|
+
return {
|
|
452
|
+
value: bounded.value,
|
|
453
|
+
truncated: bounded.truncated,
|
|
454
|
+
limit: { maxEntries: 100, maxCharacters: 10_000 },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
589
457
|
if (
|
|
590
458
|
(method === 'page.get_console' || method === 'page.get_network' || method === 'log.tail') &&
|
|
591
459
|
Array.isArray(result.entries)
|
|
@@ -661,15 +529,123 @@ function compactDomNode(value) {
|
|
|
661
529
|
return compact;
|
|
662
530
|
}
|
|
663
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Socket-level Node error codes that mean the daemon connection itself was
|
|
534
|
+
* lost, as opposed to an application-level bridge failure.
|
|
535
|
+
*
|
|
536
|
+
* @type {ReadonlySet<string>}
|
|
537
|
+
*/
|
|
538
|
+
const CONNECTION_LOSS_CODES = new Set([
|
|
539
|
+
'ENOTCONN',
|
|
540
|
+
'ECONNREFUSED',
|
|
541
|
+
'ECONNRESET',
|
|
542
|
+
'EPIPE',
|
|
543
|
+
'ERR_SOCKET_CLOSED',
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
const RECONNECT_WAIT_TIMEOUT_MS = 5_000;
|
|
547
|
+
const RECONNECT_POLL_INTERVAL_MS = 250;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Detect thrown transport errors caused by a lost daemon connection. These
|
|
551
|
+
* never arrive as bridge failure responses because the socket died before (or
|
|
552
|
+
* while) the daemon could reply.
|
|
553
|
+
*
|
|
554
|
+
* @param {unknown} error
|
|
555
|
+
* @returns {boolean}
|
|
556
|
+
*/
|
|
557
|
+
export function isConnectionLossError(error) {
|
|
558
|
+
if (!error || typeof error !== 'object') {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
const code = /** @type {{ code?: unknown }} */ (error).code;
|
|
562
|
+
if (typeof code === 'string' && CONNECTION_LOSS_CODES.has(code)) {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
const message = error instanceof Error ? error.message : '';
|
|
566
|
+
return /Bridge socket closed|BridgeClient is not connected/u.test(message);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Wait, bounded, for the client to regain its daemon connection.
|
|
571
|
+
*
|
|
572
|
+
* When the client's own auto-reconnect loop is active it owns reconnecting and
|
|
573
|
+
* emits `reconnected`; otherwise one manual connect is attempted per poll
|
|
574
|
+
* interval. Application state is never touched here - this only restores the
|
|
575
|
+
* transport.
|
|
576
|
+
*
|
|
577
|
+
* @param {import('../../agent-client/src/client.js').BridgeClient} client
|
|
578
|
+
* @param {number} [timeoutMs=RECONNECT_WAIT_TIMEOUT_MS]
|
|
579
|
+
* @returns {Promise<boolean>}
|
|
580
|
+
*/
|
|
581
|
+
export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_TIMEOUT_MS) {
|
|
582
|
+
const deadline = Date.now() + timeoutMs;
|
|
583
|
+
while (!client.connected && Date.now() < deadline) {
|
|
584
|
+
if (client.autoReconnect) {
|
|
585
|
+
await new Promise((resolve) => {
|
|
586
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
587
|
+
/** @type {ReturnType<typeof setTimeout>} */
|
|
588
|
+
let timer;
|
|
589
|
+
const cleanup = () => {
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
client.off('reconnected', onReconnected);
|
|
592
|
+
resolve(undefined);
|
|
593
|
+
};
|
|
594
|
+
const onReconnected = () => cleanup();
|
|
595
|
+
timer = setTimeout(cleanup, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
|
|
596
|
+
client.on('reconnected', onReconnected);
|
|
597
|
+
});
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
await client.connect();
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error instanceof Error && /already connected/u.test(error.message)) {
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
// Daemon still unreachable; keep waiting inside the bounded window.
|
|
607
|
+
}
|
|
608
|
+
if (!client.connected) {
|
|
609
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
610
|
+
await new Promise((resolve) =>
|
|
611
|
+
setTimeout(resolve, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining))
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return client.connected;
|
|
616
|
+
}
|
|
617
|
+
|
|
664
618
|
/**
|
|
665
619
|
* @param {import('../../agent-client/src/client.js').BridgeClient} client
|
|
666
620
|
* @param {BridgeMethod} method
|
|
667
621
|
* @param {Record<string, unknown>} params
|
|
668
|
-
* @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
|
|
622
|
+
* @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
|
|
669
623
|
* @returns {Promise<BridgeResponse>}
|
|
670
624
|
*/
|
|
671
625
|
export async function requestBridgeWithRetry(client, method, params, options) {
|
|
672
|
-
const
|
|
626
|
+
const requestOptions = {
|
|
627
|
+
...options,
|
|
628
|
+
mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore(),
|
|
629
|
+
};
|
|
630
|
+
/** @type {BridgeResponse} */
|
|
631
|
+
let response;
|
|
632
|
+
try {
|
|
633
|
+
response = await requestBridge(client, method, params, requestOptions);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
process.stderr.write(
|
|
639
|
+
`[bbx-mcp] ${method} lost its daemon connection; waiting up to ${RECONNECT_WAIT_TIMEOUT_MS}ms for reconnect before one retry.\n`
|
|
640
|
+
);
|
|
641
|
+
if (!(await waitForClientReconnect(client))) {
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
return requestBridge(client, method, params, {
|
|
645
|
+
...requestOptions,
|
|
646
|
+
automaticRetry: 'mcp_second_attempt',
|
|
647
|
+
});
|
|
648
|
+
}
|
|
673
649
|
const recovery =
|
|
674
650
|
!response.ok && response.error
|
|
675
651
|
? (response.error.recovery ?? getErrorRecovery(response.error.code))
|
|
@@ -681,7 +657,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
|
|
|
681
657
|
);
|
|
682
658
|
await new Promise((r) => setTimeout(r, delay));
|
|
683
659
|
return requestBridge(client, method, params, {
|
|
684
|
-
...
|
|
660
|
+
...requestOptions,
|
|
685
661
|
automaticRetry: 'mcp_second_attempt',
|
|
686
662
|
});
|
|
687
663
|
}
|