@browserbridge/bbx 1.12.1 → 1.12.2
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/cli-args.js +22 -3
- package/packages/agent-client/src/cli-batch.js +14 -3
- package/packages/agent-client/src/cli.js +4 -2
- package/packages/agent-client/src/command-registry.js +1 -1
- package/packages/agent-client/src/runtime.js +12 -7
- package/packages/mcp-server/src/guidance.js +2 -1
- package/packages/mcp-server/src/handlers-navigation.js +9 -4
- package/packages/mcp-server/src/handlers-page.js +14 -1
- package/packages/mcp-server/src/handlers-utils.js +175 -1
- package/packages/mcp-server/src/handlers.js +5 -1
- package/packages/mcp-server/src/server.js +51 -4
- package/packages/native-host/src/daemon.js +42 -6
- package/packages/protocol/src/protocol.js +62 -1
- package/packages/protocol/src/types.ts +10 -0
- package/skills/browser-bridge/SKILL.md +21 -1
- package/skills/browser-bridge/references/capabilities.md +8 -6
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@ import { methodNeedsTab, parseIntArg, parseJsonObject } from './cli-helpers.js';
|
|
|
6
6
|
/** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
|
|
7
7
|
/** @typedef {import('../../protocol/src/defaults.js').BudgetPresetName} BudgetPresetName */
|
|
8
8
|
|
|
9
|
-
const CALL_USAGE =
|
|
9
|
+
const CALL_USAGE =
|
|
10
|
+
'Usage: call [--extension <connectionId>] [--browser <name>] [--profile <label>] [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson]';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Read all of stdin as UTF-8 text. Resolves once stdin closes.
|
|
@@ -149,10 +150,27 @@ export function extractPresetFlag(args) {
|
|
|
149
150
|
|
|
150
151
|
/**
|
|
151
152
|
* @param {string[]} args
|
|
152
|
-
* @returns {Promise<{ tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
|
|
153
|
+
* @returns {Promise<import('../../protocol/src/types.js').BrowserTarget & { tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
|
|
153
154
|
*/
|
|
154
155
|
export async function parseCallCommand(args) {
|
|
155
|
-
const
|
|
156
|
+
const remaining = [...args];
|
|
157
|
+
/** @type {import('../../protocol/src/types.js').BrowserTarget} */
|
|
158
|
+
const target = {};
|
|
159
|
+
/** @type {Array<[string, keyof import('../../protocol/src/types.js').BrowserTarget]>} */
|
|
160
|
+
const selectors = [
|
|
161
|
+
['--extension', 'extensionId'],
|
|
162
|
+
['--browser', 'targetBrowser'],
|
|
163
|
+
['--profile', 'targetProfile'],
|
|
164
|
+
];
|
|
165
|
+
for (const [flag, field] of selectors) {
|
|
166
|
+
const index = remaining.indexOf(flag);
|
|
167
|
+
if (index === -1) continue;
|
|
168
|
+
const value = remaining[index + 1];
|
|
169
|
+
if (!value?.trim() || value.startsWith('--')) throw new Error(`${flag} requires a value.`);
|
|
170
|
+
target[field] = value;
|
|
171
|
+
remaining.splice(index, 2);
|
|
172
|
+
}
|
|
173
|
+
const presetParsed = extractPresetFlag(remaining);
|
|
156
174
|
const parsed = extractTabFlag(presetParsed.rest);
|
|
157
175
|
const [first, second, ...extra] = parsed.rest;
|
|
158
176
|
if (!first) {
|
|
@@ -173,6 +191,7 @@ export async function parseCallCommand(args) {
|
|
|
173
191
|
rawParams = await readStdin();
|
|
174
192
|
}
|
|
175
193
|
return {
|
|
194
|
+
...target,
|
|
176
195
|
method,
|
|
177
196
|
tabId: methodNeedsTab(method) ? parsed.tabId : null,
|
|
178
197
|
params: applyMethodBudgetPreset(method, parseJsonObject(rawParams), presetParsed.preset),
|
|
@@ -33,7 +33,7 @@ import { requestBridge } from './runtime.js';
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* @typedef {{ method: BridgeMethod, params: Record<string, unknown>, tabId: number | null }} PreparedBatchCall
|
|
36
|
+
* @typedef {import('../../protocol/src/types.js').BrowserTarget & { method: BridgeMethod, params: Record<string, unknown>, tabId: number | null }} PreparedBatchCall
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -107,10 +107,11 @@ export async function runBatchCalls(client, input, source, options = {}) {
|
|
|
107
107
|
const validPrepared = /** @type {Array<{ value: PreparedBatchCall }>} */ (prepared);
|
|
108
108
|
if (!client.connected) await client.connect();
|
|
109
109
|
return mapWithConcurrency(validPrepared, MAX_BATCH_CONCURRENCY, async (item) => {
|
|
110
|
-
const { method, params, tabId } = item.value;
|
|
110
|
+
const { method, params, tabId, ...target } = item.value;
|
|
111
111
|
const startTime = Date.now();
|
|
112
112
|
try {
|
|
113
113
|
const response = await requestBridge(client, method, params, {
|
|
114
|
+
...target,
|
|
114
115
|
tabId,
|
|
115
116
|
source,
|
|
116
117
|
});
|
|
@@ -150,6 +151,16 @@ function prepareBatchCall(call, preset) {
|
|
|
150
151
|
};
|
|
151
152
|
}
|
|
152
153
|
const method = /** @type {BridgeMethod} */ (batchCall.method);
|
|
154
|
+
/** @type {import('../../protocol/src/types.js').BrowserTarget} */
|
|
155
|
+
const target = {};
|
|
156
|
+
for (const field of /** @type {const} */ (['extensionId', 'targetBrowser', 'targetProfile'])) {
|
|
157
|
+
const value = batchCall[field];
|
|
158
|
+
if (value === undefined) continue;
|
|
159
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
160
|
+
return { error: invalidBatchItem(method, `${field} must be a non-empty string.`) };
|
|
161
|
+
}
|
|
162
|
+
target[field] = value;
|
|
163
|
+
}
|
|
153
164
|
if (method === 'sensitive.read') {
|
|
154
165
|
return {
|
|
155
166
|
error: invalidBatchItem(
|
|
@@ -182,7 +193,7 @@ function prepareBatchCall(call, preset) {
|
|
|
182
193
|
batchCall.tabId > 0
|
|
183
194
|
? batchCall.tabId
|
|
184
195
|
: null;
|
|
185
|
-
return { value: { method, params: mergedParams, tabId } };
|
|
196
|
+
return { value: { ...target, method, params: mergedParams, tabId } };
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
/**
|
|
@@ -393,8 +393,9 @@ async function main() {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
if (command === 'call') {
|
|
396
|
-
const { tabId, method, params } = await parseCallCommand(rest);
|
|
396
|
+
const { tabId, method, params, ...target } = await parseCallCommand(rest);
|
|
397
397
|
const response = await requestBridge(client, method, params, {
|
|
398
|
+
...target,
|
|
398
399
|
tabId,
|
|
399
400
|
source: REQUEST_SOURCE,
|
|
400
401
|
});
|
|
@@ -413,8 +414,9 @@ async function main() {
|
|
|
413
414
|
}
|
|
414
415
|
|
|
415
416
|
if (command.includes('.') && METHODS.includes(/** @type {BridgeMethod} */ (command))) {
|
|
416
|
-
const { tabId, method, params } = await parseCallCommand([command, ...rest]);
|
|
417
|
+
const { tabId, method, params, ...target } = await parseCallCommand([command, ...rest]);
|
|
417
418
|
const response = await requestBridge(client, method, params, {
|
|
419
|
+
...target,
|
|
418
420
|
tabId,
|
|
419
421
|
source: REQUEST_SOURCE,
|
|
420
422
|
});
|
|
@@ -294,7 +294,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
294
294
|
{
|
|
295
295
|
title: 'Generic RPC',
|
|
296
296
|
lines: [
|
|
297
|
-
'bbx call [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
|
|
297
|
+
'bbx call [--extension <connectionId>] [--browser <name>] [--profile <label>] [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
|
|
298
298
|
'bbx <method> [--tab <tabId>] [paramsJson|-] Direct alias for exact bridge methods such as page.get_state',
|
|
299
299
|
"bbx batch [--preset quick|normal|deep] '[{method,params,tabId?},...]' Up to 20 parallel read-only calls",
|
|
300
300
|
'--preset fills budget gaps (maxNodes, maxDepth, textBudget, limit) from the shared quick|normal|deep presets; explicit params always win.',
|
|
@@ -107,7 +107,7 @@ export async function ensureClientConnected(client) {
|
|
|
107
107
|
* @param {BridgeClient} client
|
|
108
108
|
* @param {BridgeMethod} method
|
|
109
109
|
* @param {Record<string, unknown>} [params={}]
|
|
110
|
-
* @param {{ tabId?: number | null, source?: BridgeRequestSource, mcpEra?: import('./types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
|
|
110
|
+
* @param {import('../../protocol/src/types.js').BrowserTarget & { tabId?: number | null, source?: BridgeRequestSource, mcpEra?: import('./types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
|
|
111
111
|
* @returns {Promise<BridgeResponse>}
|
|
112
112
|
*/
|
|
113
113
|
export async function requestBridge(client, method, params = {}, options = {}) {
|
|
@@ -128,12 +128,17 @@ export async function requestBridge(client, method, params = {}, options = {}) {
|
|
|
128
128
|
method,
|
|
129
129
|
params,
|
|
130
130
|
tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
|
|
131
|
-
meta:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
meta: {
|
|
132
|
+
...withRequestMeta(
|
|
133
|
+
options.source,
|
|
134
|
+
options.mcpEra,
|
|
135
|
+
options.tokenBudget,
|
|
136
|
+
options.automaticRetry
|
|
137
|
+
),
|
|
138
|
+
...(options.extensionId !== undefined ? { target_extension: options.extensionId } : {}),
|
|
139
|
+
...(options.targetBrowser !== undefined ? { target_browser: options.targetBrowser } : {}),
|
|
140
|
+
...(options.targetProfile !== undefined ? { target_profile: options.targetProfile } : {}),
|
|
141
|
+
},
|
|
137
142
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
138
143
|
});
|
|
139
144
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
const SHARED_INSTRUCTIONS = Object.freeze([
|
|
9
9
|
'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
|
|
10
|
-
'Start with browser_call method health.ping.
|
|
10
|
+
'Start with browser_call method health.ping. Its access status describes only the routed profile, not every window. Use connectedExtensions or unscoped tabs.list to discover profiles and existing tabs. Reuse the selected extensionId, destinationId when remote, and tabId on every follow-up call, including each browser_batch item. tabId alone does not select a profile. Never disable unrelated windows to fix routing. If a connection disappears, rediscover it instead of switching profiles or using guessed IDs.',
|
|
11
|
+
"If the selected profile has window access disabled, call browser_call method access.request once with its extensionId, ask the user to click Enable in that profile's Browser Bridge popup or side panel, then retry once with the same target. Restricted pages and ambiguous_browser_target are routing problems, not evidence that every window needs enabling.",
|
|
11
12
|
'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.',
|
|
12
13
|
'Reuse elementRef values returned by DOM reads. Use attribute allowlists for focused DOM reads.',
|
|
13
14
|
'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.',
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createToolResult,
|
|
6
6
|
getBridgeDestinations,
|
|
7
7
|
getToolTokenBudget,
|
|
8
|
+
hasMcpBrowserTarget,
|
|
8
9
|
requestBridgeWithRetry,
|
|
9
10
|
summarizeToolError,
|
|
10
11
|
withMcpRequestClient,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
*/
|
|
20
21
|
export async function handleTabsTool(args) {
|
|
21
22
|
if (args.action === 'list') {
|
|
22
|
-
if (typeof args.destinationId === 'string') {
|
|
23
|
+
if (typeof args.destinationId === 'string' || hasMcpBrowserTarget()) {
|
|
23
24
|
return callBridgeTool('tabs.list', {}, { destinationId: args.destinationId });
|
|
24
25
|
}
|
|
25
26
|
const destinations = await getBridgeDestinations();
|
|
@@ -51,10 +52,14 @@ export async function handleTabsTool(args) {
|
|
|
51
52
|
tabs: [],
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
|
-
const result =
|
|
55
|
+
const result =
|
|
56
|
+
/** @type {{ tabs?: Array<Record<string, unknown>>, profiles?: unknown[], partial?: boolean }} */ (
|
|
57
|
+
response.result
|
|
58
|
+
);
|
|
55
59
|
return {
|
|
56
60
|
destinationId: destination.id,
|
|
57
|
-
ok: true,
|
|
61
|
+
ok: result.partial !== true,
|
|
62
|
+
...(result.profiles ? { profiles: result.profiles } : {}),
|
|
58
63
|
tabs: (result.tabs ?? []).map((tab) => ({ destinationId: destination.id, ...tab })),
|
|
59
64
|
};
|
|
60
65
|
} catch (error) {
|
|
@@ -78,7 +83,7 @@ export async function handleTabsTool(args) {
|
|
|
78
83
|
tabs,
|
|
79
84
|
destinations: results,
|
|
80
85
|
},
|
|
81
|
-
failures.length === results.length
|
|
86
|
+
failures.length === results.length && tabs.length === 0
|
|
82
87
|
);
|
|
83
88
|
}
|
|
84
89
|
if (args.action === 'create') {
|
|
@@ -347,7 +347,7 @@ export async function handleSensitiveReadTool(args) {
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
/**
|
|
350
|
-
* @param {{ calls?: Array<{ method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
|
|
350
|
+
* @param {{ calls?: Array<import('../../protocol/src/types.js').BrowserTarget & { method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
|
|
351
351
|
* @returns {Promise<ToolResult>}
|
|
352
352
|
*/
|
|
353
353
|
export async function handleBatchTool(args) {
|
|
@@ -393,6 +393,9 @@ export async function handleBatchTool(args) {
|
|
|
393
393
|
response = await withMcpRequestClient(
|
|
394
394
|
(client) =>
|
|
395
395
|
requestBridgeWithRetry(client, method, params, {
|
|
396
|
+
...(call.extensionId ? { extensionId: call.extensionId } : {}),
|
|
397
|
+
...(call.targetBrowser ? { targetBrowser: call.targetBrowser } : {}),
|
|
398
|
+
...(call.targetProfile ? { targetProfile: call.targetProfile } : {}),
|
|
396
399
|
tabId,
|
|
397
400
|
source: REQUEST_SOURCE,
|
|
398
401
|
tokenBudget,
|
|
@@ -405,15 +408,25 @@ export async function handleBatchTool(args) {
|
|
|
405
408
|
if (callError || !response) {
|
|
406
409
|
return {
|
|
407
410
|
destinationId,
|
|
411
|
+
...(call.extensionId ? { extensionId: call.extensionId } : {}),
|
|
408
412
|
...summarizeThrownBatchError(method, tabId, callError, Date.now() - startTime),
|
|
409
413
|
};
|
|
410
414
|
}
|
|
415
|
+
const discovery = method === 'tabs.list' ? summarizeToolResponse(response, method) : null;
|
|
411
416
|
return {
|
|
412
417
|
destinationId,
|
|
418
|
+
...(call.extensionId ? { extensionId: call.extensionId } : {}),
|
|
413
419
|
...summarizeBatchResponseItem(
|
|
414
420
|
{ method, tabId, response, durationMs: Date.now() - startTime },
|
|
415
421
|
{ compact: true }
|
|
416
422
|
),
|
|
423
|
+
...(discovery
|
|
424
|
+
? {
|
|
425
|
+
summary: discovery.content[0].text,
|
|
426
|
+
evidence: discovery.structuredContent.evidence,
|
|
427
|
+
...(discovery.structuredContent.outputTruncated ? { outputTruncated: true } : {}),
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
417
430
|
};
|
|
418
431
|
});
|
|
419
432
|
|
|
@@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
BridgeError,
|
|
8
|
+
createSuccess,
|
|
9
|
+
createFailure,
|
|
8
10
|
bridgeMethodNeedsTab,
|
|
9
11
|
DEFAULT_CONSOLE_LIMIT,
|
|
10
12
|
DEFAULT_LOG_TAIL_LIMIT,
|
|
@@ -51,6 +53,33 @@ const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
|
|
|
51
53
|
const MCP_AGENT_SESSION = process.env.BBX_SESSION?.trim() || MCP_CLIENT_ID;
|
|
52
54
|
/** @type {AsyncLocalStorage<{ era: import('../../protocol/src/types.js').McpProtocolEra, signal?: AbortSignal }>} */
|
|
53
55
|
const MCP_REQUEST_ERA = new AsyncLocalStorage();
|
|
56
|
+
/** @type {AsyncLocalStorage<import('../../protocol/src/types.js').BrowserTarget>} */
|
|
57
|
+
const MCP_REQUEST_TARGET = new AsyncLocalStorage();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scope routing to one tool invocation, including selector resolution and retries.
|
|
61
|
+
* @template T
|
|
62
|
+
* @param {unknown} args
|
|
63
|
+
* @param {() => T} callback
|
|
64
|
+
* @returns {T}
|
|
65
|
+
*/
|
|
66
|
+
export function runWithMcpRequestTarget(args, callback) {
|
|
67
|
+
const record =
|
|
68
|
+
args && typeof args === 'object' ? /** @type {Record<string, unknown>} */ (args) : {};
|
|
69
|
+
const target = {
|
|
70
|
+
...(MCP_REQUEST_TARGET.getStore() ?? {}),
|
|
71
|
+
...(typeof record.extensionId === 'string' ? { extensionId: record.extensionId } : {}),
|
|
72
|
+
...(typeof record.targetBrowser === 'string' ? { targetBrowser: record.targetBrowser } : {}),
|
|
73
|
+
...(typeof record.targetProfile === 'string' ? { targetProfile: record.targetProfile } : {}),
|
|
74
|
+
};
|
|
75
|
+
return MCP_REQUEST_TARGET.run(target, callback);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @returns {boolean} */
|
|
79
|
+
export function hasMcpBrowserTarget() {
|
|
80
|
+
const target = MCP_REQUEST_TARGET.getStore();
|
|
81
|
+
return Boolean(target?.extensionId || target?.targetBrowser || target?.targetProfile);
|
|
82
|
+
}
|
|
54
83
|
|
|
55
84
|
/**
|
|
56
85
|
* @template T
|
|
@@ -275,6 +304,16 @@ export function summarizeToolResponse(response, method, params = {}) {
|
|
|
275
304
|
const summary = annotateBridgeSummary(summarizeBridgeResponse(response, method), response);
|
|
276
305
|
const summaryRecord = /** @type {Record<string, unknown>} */ (summary);
|
|
277
306
|
if (response.ok) {
|
|
307
|
+
if (
|
|
308
|
+
method === 'tabs.list' &&
|
|
309
|
+
response.result &&
|
|
310
|
+
typeof response.result === 'object' &&
|
|
311
|
+
'partial' in response.result &&
|
|
312
|
+
response.result.partial === true
|
|
313
|
+
) {
|
|
314
|
+
summary.summary +=
|
|
315
|
+
' Some browser profiles could not be listed; inspect evidence.profiles before assuming a tab is absent.';
|
|
316
|
+
}
|
|
278
317
|
const evidence = getRequestAwareEvidence(response.result, method, params, summary.evidence);
|
|
279
318
|
summary.evidence = evidence.value;
|
|
280
319
|
if (evidence.metadata) summaryRecord.evidenceMeta = evidence.metadata;
|
|
@@ -505,6 +544,35 @@ function getRequestAwareEvidence(rawResult, method, params, fallback) {
|
|
|
505
544
|
rawResult && typeof rawResult === 'object' && !Array.isArray(rawResult)
|
|
506
545
|
? /** @type {Record<string, unknown>} */ (rawResult)
|
|
507
546
|
: {};
|
|
547
|
+
if (method === 'tabs.list' && Array.isArray(result.tabs)) {
|
|
548
|
+
const tabs = /** @type {unknown[]} */ (result.tabs)
|
|
549
|
+
.filter((tab) => tab && typeof tab === 'object' && !Array.isArray(tab))
|
|
550
|
+
.map((tab) => /** @type {Record<string, unknown>} */ (tab))
|
|
551
|
+
.sort(
|
|
552
|
+
(left, right) =>
|
|
553
|
+
Number(right.working === true || right.active === true) -
|
|
554
|
+
Number(left.working === true || left.active === true)
|
|
555
|
+
)
|
|
556
|
+
.map((tab) => ({
|
|
557
|
+
...(typeof tab.extensionId === 'string' ? { extensionId: tab.extensionId } : {}),
|
|
558
|
+
tabId: tab.tabId,
|
|
559
|
+
windowId: tab.windowId,
|
|
560
|
+
active: tab.active,
|
|
561
|
+
...(tab.working === true ? { working: true } : {}),
|
|
562
|
+
...(tab.browserName !== undefined ? { browserName: tab.browserName } : {}),
|
|
563
|
+
...(tab.profileLabel !== undefined ? { profileLabel: tab.profileLabel } : {}),
|
|
564
|
+
title: typeof tab.title === 'string' ? tab.title.slice(0, 200) : tab.title,
|
|
565
|
+
origin: tab.origin,
|
|
566
|
+
...(typeof tab.url === 'string'
|
|
567
|
+
? { url: tab.url.slice(0, 512), ...(tab.url.length > 512 ? { urlTruncated: true } : {}) }
|
|
568
|
+
: {}),
|
|
569
|
+
}));
|
|
570
|
+
const metadata = Object.fromEntries(Object.entries(result).filter(([key]) => key !== 'tabs'));
|
|
571
|
+
return boundToolValue(
|
|
572
|
+
{ tabCount: tabs.length, ...metadata, tabs },
|
|
573
|
+
{ maxEntries: 2000, maxCharacters: 30_000, maxStringLength: 1000 }
|
|
574
|
+
);
|
|
575
|
+
}
|
|
508
576
|
if (method === 'page.extract_content' && typeof result.content === 'string') {
|
|
509
577
|
const requested = positiveInteger(params.textBudget) ?? DEFAULT_PAGE_TEXT_BUDGET;
|
|
510
578
|
const maxCharacters = Math.min(requested, 16_000);
|
|
@@ -757,14 +825,24 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
|
|
|
757
825
|
* @param {import('../../agent-client/src/client.js').BridgeClient} client
|
|
758
826
|
* @param {BridgeMethod} method
|
|
759
827
|
* @param {Record<string, unknown>} params
|
|
760
|
-
* @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
|
|
828
|
+
* @param {import('../../protocol/src/types.js').BrowserTarget & { tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt', skipProfileDiscovery?: boolean }} options
|
|
761
829
|
* @returns {Promise<BridgeResponse>}
|
|
762
830
|
*/
|
|
763
831
|
export async function requestBridgeWithRetry(client, method, params, options) {
|
|
764
832
|
const requestOptions = {
|
|
833
|
+
...(MCP_REQUEST_TARGET.getStore() ?? {}),
|
|
765
834
|
...options,
|
|
766
835
|
mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore()?.era,
|
|
767
836
|
};
|
|
837
|
+
if (
|
|
838
|
+
method === 'tabs.list' &&
|
|
839
|
+
!options.skipProfileDiscovery &&
|
|
840
|
+
!requestOptions.extensionId &&
|
|
841
|
+
!requestOptions.targetBrowser &&
|
|
842
|
+
!requestOptions.targetProfile
|
|
843
|
+
) {
|
|
844
|
+
return listBrowserProfileTabs(client, params, requestOptions);
|
|
845
|
+
}
|
|
768
846
|
/** @param {typeof requestOptions} attemptOptions */
|
|
769
847
|
const attempt = (attemptOptions) =>
|
|
770
848
|
awaitMcpOperation(async () => {
|
|
@@ -811,6 +889,102 @@ export async function requestBridgeWithRetry(client, method, params, options) {
|
|
|
811
889
|
return response;
|
|
812
890
|
}
|
|
813
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Discover only enabled profiles. Reading tabs never chooses a working tab.
|
|
894
|
+
* @param {import('../../agent-client/src/client.js').BridgeClient} client
|
|
895
|
+
* @param {Record<string, unknown>} params
|
|
896
|
+
* @param {Parameters<typeof requestBridgeWithRetry>[3]} options
|
|
897
|
+
* @returns {Promise<BridgeResponse>}
|
|
898
|
+
*/
|
|
899
|
+
async function listBrowserProfileTabs(client, params, options) {
|
|
900
|
+
const health = await requestBridgeWithRetry(client, 'health.ping', {}, options);
|
|
901
|
+
if (!health.ok) return health;
|
|
902
|
+
const result =
|
|
903
|
+
health.result && typeof health.result === 'object'
|
|
904
|
+
? /** @type {Record<string, unknown>} */ (health.result)
|
|
905
|
+
: {};
|
|
906
|
+
const profiles = Array.isArray(result.connectedExtensions)
|
|
907
|
+
? /** @type {unknown[]} */ (result.connectedExtensions)
|
|
908
|
+
.filter((value) => value && typeof value === 'object' && !Array.isArray(value))
|
|
909
|
+
.map((value) => /** @type {Record<string, unknown>} */ (value))
|
|
910
|
+
.filter((value) => typeof value.extensionId === 'string' && value.accessEnabled === true)
|
|
911
|
+
.map((value) => /** @type {Record<string, unknown> & { extensionId: string }} */ (value))
|
|
912
|
+
: [];
|
|
913
|
+
// Older daemons and a disabled single profile keep the usual access response.
|
|
914
|
+
if (
|
|
915
|
+
profiles.length === 0 &&
|
|
916
|
+
(!Array.isArray(result.connectedExtensions) || result.connectedExtensions.length <= 1)
|
|
917
|
+
) {
|
|
918
|
+
return requestBridgeWithRetry(client, 'tabs.list', params, {
|
|
919
|
+
...options,
|
|
920
|
+
skipProfileDiscovery: true,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (profiles.length === 0) {
|
|
924
|
+
return createFailure(
|
|
925
|
+
health.id,
|
|
926
|
+
'ACCESS_DENIED',
|
|
927
|
+
'No connected profile has an enabled window. Select an extensionId from connectedExtensions and request access only for that profile.',
|
|
928
|
+
{
|
|
929
|
+
reason: 'no_enabled_browser_profiles',
|
|
930
|
+
connectedExtensions: result.connectedExtensions,
|
|
931
|
+
},
|
|
932
|
+
{ method: 'tabs.list' }
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
const results = [];
|
|
936
|
+
for (const profile of profiles) {
|
|
937
|
+
/** @type {BridgeResponse} */
|
|
938
|
+
let response;
|
|
939
|
+
try {
|
|
940
|
+
response = await requestBridgeWithRetry(client, 'tabs.list', params, {
|
|
941
|
+
...options,
|
|
942
|
+
extensionId: profile.extensionId,
|
|
943
|
+
});
|
|
944
|
+
} catch (error) {
|
|
945
|
+
throwIfMcpRequestCancelled();
|
|
946
|
+
response = createFailure(
|
|
947
|
+
health.id,
|
|
948
|
+
error instanceof BridgeError ? error.code : 'INTERNAL_ERROR',
|
|
949
|
+
error instanceof Error ? error.message : String(error),
|
|
950
|
+
null,
|
|
951
|
+
{ method: 'tabs.list' }
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
const listed =
|
|
955
|
+
response.ok && response.result && typeof response.result === 'object'
|
|
956
|
+
? /** @type {Record<string, unknown>} */ (response.result)
|
|
957
|
+
: {};
|
|
958
|
+
results.push({
|
|
959
|
+
extensionId: profile.extensionId,
|
|
960
|
+
browserName: profile.browserName ?? null,
|
|
961
|
+
profileLabel: profile.profileLabel ?? null,
|
|
962
|
+
ok: response.ok,
|
|
963
|
+
...(response.ok ? {} : { error: response.error }),
|
|
964
|
+
tabs: Array.isArray(listed.tabs)
|
|
965
|
+
? /** @type {unknown[]} */ (listed.tabs)
|
|
966
|
+
.filter((tab) => tab && typeof tab === 'object' && !Array.isArray(tab))
|
|
967
|
+
.map((tab) => ({
|
|
968
|
+
.../** @type {Record<string, unknown>} */ (tab),
|
|
969
|
+
extensionId: profile.extensionId,
|
|
970
|
+
browserName: profile.browserName ?? null,
|
|
971
|
+
profileLabel: profile.profileLabel ?? null,
|
|
972
|
+
}))
|
|
973
|
+
: [],
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
return createSuccess(
|
|
977
|
+
health.id,
|
|
978
|
+
{
|
|
979
|
+
tabs: results.flatMap((profile) => profile.tabs),
|
|
980
|
+
profiles: results.map(({ tabs, ...profile }) => ({ ...profile, tabCount: tabs.length })),
|
|
981
|
+
connectedExtensions: result.connectedExtensions,
|
|
982
|
+
partial: results.some((profile) => !profile.ok),
|
|
983
|
+
},
|
|
984
|
+
{ method: 'tabs.list' }
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
814
988
|
/**
|
|
815
989
|
* @param {BridgeMethod} method
|
|
816
990
|
* @param {Record<string, unknown>} [params={}]
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
summarizeToolResponse,
|
|
17
17
|
withToolClient,
|
|
18
18
|
REQUEST_SOURCE,
|
|
19
|
+
hasMcpBrowserTarget,
|
|
19
20
|
} from './handlers-utils.js';
|
|
20
21
|
|
|
21
22
|
export {
|
|
@@ -81,6 +82,9 @@ const HOME_DIR = os.homedir();
|
|
|
81
82
|
*/
|
|
82
83
|
export async function handleStatusTool(args = {}) {
|
|
83
84
|
try {
|
|
85
|
+
if (hasMcpBrowserTarget()) {
|
|
86
|
+
return callBridgeTool('health.ping', {}, { destinationId: args.destinationId });
|
|
87
|
+
}
|
|
84
88
|
const requestedDestinationId =
|
|
85
89
|
typeof args.destinationId === 'string' ? args.destinationId : null;
|
|
86
90
|
if (requestedDestinationId && requestedDestinationId !== 'local') {
|
|
@@ -172,7 +176,7 @@ async function callRemoteHealth(destinationId) {
|
|
|
172
176
|
*/
|
|
173
177
|
export async function handleSkillTool(args = {}) {
|
|
174
178
|
try {
|
|
175
|
-
if (typeof args.destinationId === 'string') {
|
|
179
|
+
if (typeof args.destinationId === 'string' || hasMcpBrowserTarget()) {
|
|
176
180
|
return callBridgeTool('skill.get_runtime_context', {}, { destinationId: args.destinationId });
|
|
177
181
|
}
|
|
178
182
|
const ctx = createRuntimeContext();
|
|
@@ -54,13 +54,44 @@ import {
|
|
|
54
54
|
import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
|
|
55
55
|
import { getMcpServerInstructions } from './guidance.js';
|
|
56
56
|
import { isToolEnabledForEra, LOADABLE_TOOLSET_TOOLS } from './toolset.js';
|
|
57
|
+
import { runWithMcpRequestTarget } from './handlers-utils.js';
|
|
57
58
|
|
|
58
59
|
export const BUDGET_PRESET_DESCRIPTION = `Budget preset: "quick", "normal", or "deep" (defaults: query ${BUDGET_PRESETS.normal.maxNodes} nodes / depth ${BUDGET_PRESETS.normal.maxDepth} / text ${BUDGET_PRESETS.normal.textBudget}). Numeric fields override the preset when both are provided.`;
|
|
59
60
|
export const TAB_ID_DESCRIPTION =
|
|
60
|
-
'
|
|
61
|
+
'Tab ID within the selected browser profile. With multiple profiles, also pass extensionId from tabs.list or health.ping.';
|
|
61
62
|
export const DESTINATION_ID_DESCRIPTION =
|
|
62
63
|
'Optional Browser Bridge destination ID from browser_status/browser_tabs; omit for local.';
|
|
63
64
|
|
|
65
|
+
const BROWSER_TARGET_SCHEMA = {
|
|
66
|
+
extensionId: z
|
|
67
|
+
.string()
|
|
68
|
+
.trim()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(256)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe(
|
|
73
|
+
'Exact browser connection ID from health.ping connectedExtensions or tabs.list. Reuse with tabId on every call; IDs are scoped to destinationId and change on reconnect.'
|
|
74
|
+
),
|
|
75
|
+
targetBrowser: z
|
|
76
|
+
.string()
|
|
77
|
+
.trim()
|
|
78
|
+
.min(1)
|
|
79
|
+
.max(256)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(
|
|
82
|
+
'Browser name from health.ping connectedExtensions. Prefer extensionId when names are ambiguous.'
|
|
83
|
+
),
|
|
84
|
+
targetProfile: z
|
|
85
|
+
.string()
|
|
86
|
+
.trim()
|
|
87
|
+
.min(1)
|
|
88
|
+
.max(256)
|
|
89
|
+
.optional()
|
|
90
|
+
.describe(
|
|
91
|
+
'Profile label from health.ping connectedExtensions. Prefer extensionId when labels are missing or duplicated.'
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
64
95
|
const MCP_SERVER_VERSION = loadPackageVersion();
|
|
65
96
|
|
|
66
97
|
/**
|
|
@@ -173,9 +204,24 @@ export function createBridgeMcpServer(options = {}) {
|
|
|
173
204
|
const registerTool = (name, config, handler) => {
|
|
174
205
|
const wrappedHandler = /** @type {typeof handler} */ (
|
|
175
206
|
(args, context) =>
|
|
176
|
-
runWithMcpRequestEra(
|
|
207
|
+
runWithMcpRequestEra(
|
|
208
|
+
era,
|
|
209
|
+
() => runWithMcpRequestTarget(args, () => handler(args, context)),
|
|
210
|
+
context.mcpReq.signal
|
|
211
|
+
)
|
|
177
212
|
);
|
|
178
|
-
const
|
|
213
|
+
const routedConfig =
|
|
214
|
+
name === 'browser_setup' || name === 'browser_toolset'
|
|
215
|
+
? config
|
|
216
|
+
: {
|
|
217
|
+
...config,
|
|
218
|
+
// Added optional routing fields are consumed by the wrapper; handlers keep
|
|
219
|
+
// their original, SDK-checked argument contracts.
|
|
220
|
+
inputSchema: /** @type {InputSchema} */ (
|
|
221
|
+
/** @type {unknown} */ (config.inputSchema.extend(BROWSER_TARGET_SCHEMA))
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
const registration = server.registerTool(name, routedConfig, wrappedHandler);
|
|
179
225
|
registrations.set(name, registration);
|
|
180
226
|
if (!isToolEnabledForEra(name, era)) {
|
|
181
227
|
registration.disable();
|
|
@@ -257,7 +303,7 @@ export function createBridgeMcpServer(options = {}) {
|
|
|
257
303
|
{
|
|
258
304
|
title: 'Browser Tabs',
|
|
259
305
|
description:
|
|
260
|
-
|
|
306
|
+
"List, create, close, or activate browser tabs. Unscoped list discovers tabs across enabled browser profiles and configured destinations. Reuse each tab's extensionId, destinationId, and tabId for subsequent calls. Other actions default to local. Only create a page when the user explicitly requests it.",
|
|
261
307
|
inputSchema: z.object({
|
|
262
308
|
action: z
|
|
263
309
|
.enum(['list', 'create', 'close', 'activate'])
|
|
@@ -1051,6 +1097,7 @@ export function createBridgeMcpServer(options = {}) {
|
|
|
1051
1097
|
.describe('Method params for this call'),
|
|
1052
1098
|
tabId: z.number().int().positive().optional().describe(TAB_ID_DESCRIPTION),
|
|
1053
1099
|
destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
|
|
1100
|
+
...BROWSER_TARGET_SCHEMA,
|
|
1054
1101
|
budgetPreset: z
|
|
1055
1102
|
.enum(['quick', 'normal', 'deep'])
|
|
1056
1103
|
.optional()
|
|
@@ -1282,7 +1282,9 @@ export class BridgeDaemon {
|
|
|
1282
1282
|
typeof request.meta?.target_browser === 'string' ? request.meta.target_browser : null;
|
|
1283
1283
|
const targetProfile =
|
|
1284
1284
|
typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
|
|
1285
|
-
const
|
|
1285
|
+
const targetExtension =
|
|
1286
|
+
typeof request.meta?.target_extension === 'string' ? request.meta.target_extension : null;
|
|
1287
|
+
const hasExplicitTarget = Boolean(targetBrowser || targetProfile || targetExtension);
|
|
1286
1288
|
|
|
1287
1289
|
const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
|
|
1288
1290
|
if (
|
|
@@ -1311,13 +1313,42 @@ export class BridgeDaemon {
|
|
|
1311
1313
|
request.method === 'dom.baseline.describe' ||
|
|
1312
1314
|
request.method === 'dom.baseline.release';
|
|
1313
1315
|
const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
|
|
1316
|
+
const candidates = [...this.extensionSockets.entries()].filter(
|
|
1317
|
+
([id, extension]) =>
|
|
1318
|
+
(!targetExtension || id === targetExtension) &&
|
|
1319
|
+
(!targetBrowser || extension.__browserName === targetBrowser) &&
|
|
1320
|
+
(!targetProfile || extension.__profileLabel === targetProfile)
|
|
1321
|
+
);
|
|
1322
|
+
if (
|
|
1323
|
+
!usesBaselineOwner &&
|
|
1324
|
+
candidates.length > 1 &&
|
|
1325
|
+
request.method !== 'health.ping' &&
|
|
1326
|
+
request.method !== 'skill.get_runtime_context'
|
|
1327
|
+
) {
|
|
1328
|
+
await writeJsonLine(socket, {
|
|
1329
|
+
type: 'agent.response',
|
|
1330
|
+
response: createFailure(
|
|
1331
|
+
request.id,
|
|
1332
|
+
ERROR_CODES.TAB_MISMATCH,
|
|
1333
|
+
'Multiple browser profiles match this call. Select a connection with extensionId in MCP or meta.target_extension in raw requests, then list its tabs. A tabId alone does not select a profile. Do not disable other windows.',
|
|
1334
|
+
{
|
|
1335
|
+
reason: 'ambiguous_browser_target',
|
|
1336
|
+
connectedExtensions: this.getConnectedExtensionsSnapshot(),
|
|
1337
|
+
},
|
|
1338
|
+
{ method: request.method }
|
|
1339
|
+
),
|
|
1340
|
+
});
|
|
1341
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1314
1344
|
const target = usesBaselineOwner
|
|
1315
1345
|
? baselineOwner &&
|
|
1346
|
+
(!targetExtension || this.extensionSockets.get(targetExtension) === baselineOwner) &&
|
|
1316
1347
|
(!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
|
|
1317
1348
|
(!targetProfile || baselineOwner.__profileLabel === targetProfile)
|
|
1318
1349
|
? baselineOwner
|
|
1319
1350
|
: null
|
|
1320
|
-
: this.selectExtensionTarget(targetBrowser, targetProfile);
|
|
1351
|
+
: this.selectExtensionTarget(targetBrowser, targetProfile, targetExtension);
|
|
1321
1352
|
|
|
1322
1353
|
if (!target) {
|
|
1323
1354
|
if (usesBaselineOwner) {
|
|
@@ -1339,9 +1370,12 @@ export class BridgeDaemon {
|
|
|
1339
1370
|
request.id,
|
|
1340
1371
|
ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
1341
1372
|
hasExplicitTarget
|
|
1342
|
-
? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
|
|
1373
|
+
? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}" target_extension="${targetExtension ?? '*'}".`
|
|
1343
1374
|
: 'The Chrome extension is not connected to the local bridge daemon.',
|
|
1344
|
-
|
|
1375
|
+
{
|
|
1376
|
+
reason: hasExplicitTarget ? 'browser_target_disconnected' : 'extension_disconnected',
|
|
1377
|
+
connectedExtensions: this.getConnectedExtensionsSnapshot(),
|
|
1378
|
+
},
|
|
1345
1379
|
{ method: request.method }
|
|
1346
1380
|
);
|
|
1347
1381
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
@@ -1418,11 +1452,13 @@ export class BridgeDaemon {
|
|
|
1418
1452
|
*
|
|
1419
1453
|
* @param {string | null} targetBrowser
|
|
1420
1454
|
* @param {string | null} targetProfile
|
|
1455
|
+
* @param {string | null} [targetExtension]
|
|
1421
1456
|
* @returns {ClientSocket | null}
|
|
1422
1457
|
*/
|
|
1423
|
-
selectExtensionTarget(targetBrowser, targetProfile) {
|
|
1424
|
-
const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
|
|
1458
|
+
selectExtensionTarget(targetBrowser, targetProfile, targetExtension = null) {
|
|
1459
|
+
const candidates = Array.from(this.extensionSockets.entries()).filter(([id, extSocket]) => {
|
|
1425
1460
|
return (
|
|
1461
|
+
(!targetExtension || id === targetExtension) &&
|
|
1426
1462
|
(!targetBrowser || extSocket.__browserName === targetBrowser) &&
|
|
1427
1463
|
(!targetProfile || extSocket.__profileLabel === targetProfile)
|
|
1428
1464
|
);
|
|
@@ -296,7 +296,7 @@ export function createSuccess(id, result, meta = {}) {
|
|
|
296
296
|
* @returns {BridgeFailureResponse}
|
|
297
297
|
*/
|
|
298
298
|
export function createFailure(id, code, message, details = null, meta = {}) {
|
|
299
|
-
const recovery = getErrorRecovery(code);
|
|
299
|
+
const recovery = getRoutingRecovery(code, details) ?? getErrorRecovery(code);
|
|
300
300
|
const effectiveRecovery =
|
|
301
301
|
recovery && meta.method === 'sensitive.read'
|
|
302
302
|
? {
|
|
@@ -322,6 +322,35 @@ export function createFailure(id, code, message, details = null, meta = {}) {
|
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Routing errors need selection guidance, not closed-tab or global enable hints.
|
|
327
|
+
* @param {import('./types.js').ErrorCode} code
|
|
328
|
+
* @param {unknown} details
|
|
329
|
+
* @returns {import('./types.js').BridgeRecovery | null}
|
|
330
|
+
*/
|
|
331
|
+
function getRoutingRecovery(code, details) {
|
|
332
|
+
const reason =
|
|
333
|
+
details && typeof details === 'object' && 'reason' in details ? details.reason : null;
|
|
334
|
+
if (
|
|
335
|
+
(code === ERROR_CODES.TAB_MISMATCH && reason === 'ambiguous_browser_target') ||
|
|
336
|
+
(code === ERROR_CODES.EXTENSION_DISCONNECTED && reason === 'browser_target_disconnected')
|
|
337
|
+
) {
|
|
338
|
+
return {
|
|
339
|
+
retry: false,
|
|
340
|
+
alternativeMethod: 'health.ping',
|
|
341
|
+
hint: 'Discover connectedExtensions with health.ping or list enabled profiles with MCP tabs.list. Select extensionId and tabId for the intended page. Do not disable unrelated windows or fall back to another profile.',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (code === ERROR_CODES.ACCESS_DENIED && reason === 'no_enabled_browser_profiles') {
|
|
345
|
+
return {
|
|
346
|
+
retry: false,
|
|
347
|
+
alternativeMethod: 'health.ping',
|
|
348
|
+
hint: "Choose the intended extensionId from connectedExtensions before requesting access. Ask the user to enable only that profile's window.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
325
354
|
/**
|
|
326
355
|
* @param {unknown} request
|
|
327
356
|
* @returns {BridgeRequest}
|
|
@@ -409,6 +438,7 @@ export function validateBridgeRequest(request) {
|
|
|
409
438
|
params: normalizeRequestParams(method, params),
|
|
410
439
|
meta: {
|
|
411
440
|
...meta,
|
|
441
|
+
...normalizeBrowserTargetMeta(meta),
|
|
412
442
|
protocol_version:
|
|
413
443
|
typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
|
|
414
444
|
token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
|
|
@@ -424,6 +454,37 @@ export function validateBridgeRequest(request) {
|
|
|
424
454
|
};
|
|
425
455
|
}
|
|
426
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Never turn a malformed explicit selector into implicit routing.
|
|
459
|
+
* @param {Record<string, unknown>} meta
|
|
460
|
+
* @returns {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>}
|
|
461
|
+
*/
|
|
462
|
+
function normalizeBrowserTargetMeta(meta) {
|
|
463
|
+
/** @type {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>} */
|
|
464
|
+
const target = {};
|
|
465
|
+
for (const field of /** @type {const} */ ([
|
|
466
|
+
'target_extension',
|
|
467
|
+
'target_browser',
|
|
468
|
+
'target_profile',
|
|
469
|
+
])) {
|
|
470
|
+
const value = meta[field];
|
|
471
|
+
if (value === undefined) continue;
|
|
472
|
+
if (
|
|
473
|
+
typeof value !== 'string' ||
|
|
474
|
+
!value.trim() ||
|
|
475
|
+
value.length > 256 ||
|
|
476
|
+
hasAsciiControlCharacters(value)
|
|
477
|
+
) {
|
|
478
|
+
throw new BridgeError(
|
|
479
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
480
|
+
`meta.${field} must be a non-empty string of at most 256 characters without control characters.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
target[field] = value.trim();
|
|
484
|
+
}
|
|
485
|
+
return target;
|
|
486
|
+
}
|
|
487
|
+
|
|
427
488
|
/** @param {string} value @returns {boolean} */
|
|
428
489
|
function hasAsciiControlCharacters(value) {
|
|
429
490
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -144,7 +144,17 @@ export type BridgeMethod =
|
|
|
144
144
|
|
|
145
145
|
export type CostClass = 'cheap' | 'moderate' | 'heavy' | 'extreme';
|
|
146
146
|
|
|
147
|
+
/** Browser connection selectors, independent of the machine destination and tab. */
|
|
148
|
+
export interface BrowserTarget {
|
|
149
|
+
extensionId?: string;
|
|
150
|
+
targetBrowser?: string;
|
|
151
|
+
targetProfile?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
147
154
|
export interface BridgeMeta {
|
|
155
|
+
target_extension?: string;
|
|
156
|
+
target_browser?: string;
|
|
157
|
+
target_profile?: string;
|
|
148
158
|
protocol_version?: string;
|
|
149
159
|
token_budget?: number | null;
|
|
150
160
|
transport_bytes?: number;
|
|
@@ -121,6 +121,26 @@ In MCP mode, pass `destinationId` on any browser tool call instead; `browser_sta
|
|
|
121
121
|
|
|
122
122
|
Browser Bridge access is window-scoped. The user turns it on once for the current browser window in the popup or side panel.
|
|
123
123
|
|
|
124
|
+
### Multiple windows and profiles
|
|
125
|
+
|
|
126
|
+
Do not disable other windows or profiles to make routing work. `health.ping` returns `connectedExtensions`; its `access` status describes only the profile that answered. A restricted active tab there does not mean the requested page in another profile is unavailable.
|
|
127
|
+
|
|
128
|
+
In MCP, an unscoped `tabs.list` discovers tabs across enabled profiles. Choose the requested page by URL/title, then pass its `extensionId` and `tabId` on every call, including each batch item. Include `destinationId` for a remote machine. `tabId` and element refs are profile-local, so neither identifies a browser profile by itself. `targetBrowser` and `targetProfile` also work when their names uniquely identify a connection; prefer `extensionId` when labels are absent or duplicated.
|
|
129
|
+
|
|
130
|
+
Use the daemon connection's `extensionId`, not `browserExtensionId`, which identifies the installed Chrome extension and may be shared across profiles.
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "method": "page.get_state", "extensionId": "<connection ID from discovery>", "tabId": 123 }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
CLI calls accept the same connection selector:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bbx call --extension '<connection ID from health.ping>' --tab 123 page.get_state '{}'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
When multiple connections match, calls fail with `TAB_MISMATCH` and `reason: ambiguous_browser_target` before reading or changing any page. Choose a connection from the error's `connectedExtensions`, list its tabs, and retry with the exact target. Connection IDs change on reconnect; rediscover a missing connection rather than falling back to another profile. Ask the user to enable access only for the selected profile if it is disabled.
|
|
143
|
+
|
|
124
144
|
To request access, call `access.request` (via `bbx access-request`, `browser_access` MCP tool, or `bbx call access.request`). This surfaces an Enable cue in the extension popup/side panel for the focused window.
|
|
125
145
|
|
|
126
146
|
Do not call `access.request` repeatedly while the same window is still pending. If access is already requested, tell the user to enable that window and wait for them to confirm readiness.
|
|
@@ -154,7 +174,7 @@ After access is enabled:
|
|
|
154
174
|
| `INPUT_FOCUS_CHANGED` | No | Inspect focus handlers; do not replay native text automatically |
|
|
155
175
|
| `DIALOG_NOT_OPEN` | No | Trigger or inspect the dialog again |
|
|
156
176
|
| `DIALOG_ACTION_CONFLICT` | No | Inspect current dialog state; never auto-repeat accept/dismiss |
|
|
157
|
-
| `TAB_MISMATCH` | No |
|
|
177
|
+
| `TAB_MISMATCH` | No | For `ambiguous_browser_target`, select `extensionId` and list its tabs. For a closed working tab, select a new `tabId`. Never disable unrelated windows. |
|
|
158
178
|
| `TIMEOUT` | Once | Retry once; if still failing, simplify (smaller `maxNodes`, narrower selector) |
|
|
159
179
|
| `CONTENT_SCRIPT_UNAVAILABLE` | No | Switch to a normal http(s) page in the enabled window |
|
|
160
180
|
| `EXTENSION_DISCONNECTED` | After 3s | Check Chrome is running; `bbx status` to verify, then retry |
|
|
@@ -9,15 +9,15 @@ Browser Bridge does not use capability-scoped sessions anymore.
|
|
|
9
9
|
## Access Model
|
|
10
10
|
|
|
11
11
|
1. The user turns Browser Bridge on for one browser window.
|
|
12
|
-
2.
|
|
13
|
-
3. Use `tabId`
|
|
12
|
+
2. With one connected profile, the first page call binds the session to its active tab. Later calls keep using that working tab even when browser focus changes.
|
|
13
|
+
3. Use `tabId` to choose a different working tab in the selected profile's enabled window.
|
|
14
14
|
4. Turning Browser Bridge off removes access immediately.
|
|
15
15
|
|
|
16
16
|
Once a window is enabled, the bridge can use all standard methods in that window, including debugger-backed methods when needed.
|
|
17
17
|
|
|
18
18
|
## Routing Defaults
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Default routing is safe when only one browser profile is connected:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
bbx status
|
|
@@ -25,7 +25,7 @@ bbx page-text
|
|
|
25
25
|
bbx dom-query main
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
If the user switches to another tab
|
|
28
|
+
If the user switches to another tab, Browser Bridge keeps using the session's working tab.
|
|
29
29
|
|
|
30
30
|
Use explicit `tabId` only for non-active tabs or deliberate side-by-side comparisons:
|
|
31
31
|
|
|
@@ -35,7 +35,9 @@ bbx call --tab 123 page.get_text
|
|
|
35
35
|
bbx call --tab 456 dom.query '{"selector":"main"}'
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
With multiple profiles, first use `health.ping` to read `connectedExtensions` or an unscoped MCP `tabs.list` to find the requested page across enabled profiles. Pass the selected `extensionId` and `tabId` on every MCP call, plus `destinationId` for a remote machine. Profile-local tab IDs and element refs are not globally unique. For CLI, use `bbx call --extension <connectionId> --tab <tabId> <method> '{...}'`. Browser/profile names are also available through MCP `targetBrowser`/`targetProfile` or CLI `--browser`/`--profile` when they uniquely identify a connection.
|
|
39
|
+
|
|
40
|
+
Ambiguous calls fail before reaching a page with `TAB_MISMATCH`, `reason: ambiguous_browser_target`, and connection choices. Never disable unrelated windows to route a call. Connection IDs change on reconnect, so rediscover a missing target rather than silently switching profiles.
|
|
39
41
|
|
|
40
42
|
## Access Failures
|
|
41
43
|
|
|
@@ -44,4 +46,4 @@ If a call fails with `ACCESS_DENIED`, `TAB_MISMATCH`, or another routing error:
|
|
|
44
46
|
1. Confirm the user enabled Browser Bridge for the correct browser window.
|
|
45
47
|
2. Confirm the target page is a normal web page, not a Chrome-restricted page.
|
|
46
48
|
3. If using explicit `tabId`, confirm that tab is inside the enabled window.
|
|
47
|
-
4.
|
|
49
|
+
4. For `ambiguous_browser_target`, choose a connection and list its tabs. Do not fall back to an unrelated active profile.
|