@browserbridge/bbx 1.7.4 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  // @ts-check
2
2
 
3
3
  import { BRIDGE_METHOD_REGISTRY, BRIDGE_METHODS } from '../../protocol/src/index.js';
4
- import { parseCommaList, parseIntArg, parsePropertyAssignments } from './cli-helpers.js';
4
+ import {
5
+ parseCommaList,
6
+ parseIntArg,
7
+ parseNumberArg,
8
+ parsePropertyAssignments,
9
+ } from './cli-helpers.js';
5
10
 
6
11
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
7
12
  /** @typedef {import('./types.js').ShortcutCommand} ShortcutCommand */
@@ -200,8 +205,8 @@ export const SHORTCUT_COMMANDS = {
200
205
  scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
201
206
  if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
202
207
  return {
203
- top: r[0] ? parseIntArg(r[0], 'top') : undefined,
204
- left: r[1] ? parseIntArg(r[1], 'left') : undefined,
208
+ top: r[0] ? parseNumberArg(r[0], 'top') : undefined,
209
+ left: r[1] ? parseNumberArg(r[1], 'left') : undefined,
205
210
  };
206
211
  }),
207
212
  resize: createShortcutCommand('viewport.resize', 'bbx resize <width> <height>', (r) => {
@@ -256,11 +261,12 @@ export const CLI_HELP_SECTIONS = Object.freeze([
256
261
  'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
257
262
  'bbx status Check bridge connection',
258
263
  'bbx doctor Diagnose install, daemon, extension, and access readiness',
259
- 'bbx restart Restart the local Browser Bridge daemon',
260
- 'bbx proxy enable [--port 9223] [--bind-host 0.0.0.0] [--rotate-token] Enable opt-in TCP proxy mode (idempotent: re-running keeps settings and token)',
264
+ 'bbx restart Restart the local daemon and running MCP servers',
265
+ 'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
266
+ ' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
261
267
  'bbx proxy disable Disable proxy mode and restart the daemon',
262
268
  'bbx proxy status Show proxy config and daemon reachability',
263
- 'bbx remote add <name> <host:port> --token <token> Add a remote Browser Bridge destination (example: remote-bbx 192.168.56.20:9223)',
269
+ 'bbx remote add <name> <host:port> (--token <token>|--token-file <path>) Add a remote Browser Bridge destination',
264
270
  'bbx remote list List remote destinations',
265
271
  'bbx remote test <name> Ping a remote destination',
266
272
  'bbx remote remove <name> Remove a remote destination',
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
8
9
  import { installMcpConfig } from './mcp-config.js';
@@ -452,10 +453,44 @@ async function installManagedSkill(skillName, target, targetDir) {
452
453
  throw new Error(`Refusing to overwrite unmanaged skill directory: ${targetDir}`);
453
454
  }
454
455
 
455
- await fs.promises.rm(targetDir, { recursive: true, force: true });
456
- await copyDir(sourceDir, targetDir);
457
- await applyManagedSkillPatches(skillName, target, targetDir);
458
- await fs.promises.writeFile(sentinelPath, formatManagedSkillSentinel(skillName), 'utf8');
456
+ await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
457
+ const suffix = `${process.pid}-${randomUUID()}`;
458
+ const temporaryDir = `${targetDir}.tmp-${suffix}`;
459
+ const backupDir = `${targetDir}.backup-${suffix}`;
460
+ let backupCreated = false;
461
+
462
+ try {
463
+ await copyDir(sourceDir, temporaryDir);
464
+ await applyManagedSkillPatches(skillName, target, temporaryDir);
465
+ await fs.promises.writeFile(
466
+ path.join(temporaryDir, managedSentinelFilename),
467
+ formatManagedSkillSentinel(skillName),
468
+ 'utf8'
469
+ );
470
+
471
+ if (targetExists) {
472
+ await fs.promises.rename(targetDir, backupDir);
473
+ backupCreated = true;
474
+ }
475
+ try {
476
+ await fs.promises.rename(temporaryDir, targetDir);
477
+ } catch (error) {
478
+ if (backupCreated) {
479
+ await fs.promises.rename(backupDir, targetDir);
480
+ backupCreated = false;
481
+ }
482
+ throw error;
483
+ }
484
+ if (backupCreated) {
485
+ await fs.promises.rm(backupDir, { recursive: true, force: true }).catch(() => {});
486
+ backupCreated = false;
487
+ }
488
+ } finally {
489
+ await fs.promises.rm(temporaryDir, { recursive: true, force: true }).catch(() => {});
490
+ if (backupCreated && !(await pathExists(targetDir))) {
491
+ await fs.promises.rename(backupDir, targetDir).catch(() => {});
492
+ }
493
+ }
459
494
  }
460
495
 
461
496
  /**
@@ -4,6 +4,8 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
+ import { atomicWriteFile } from './atomic-write.js';
8
+
7
9
  /** @typedef {import('./types.js').McpClientName} McpClientName */
8
10
 
9
11
  /** @type {McpClientName[]} */
@@ -491,6 +493,8 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
491
493
  const parsed = JSON.parse(raw);
492
494
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
493
495
  existing = parsed;
496
+ } else {
497
+ throw new Error('MCP config root must be a JSON object.');
494
498
  }
495
499
  } catch (error) {
496
500
  if (!isMissingFileError(error)) {
@@ -519,8 +523,9 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
519
523
  },
520
524
  };
521
525
 
522
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
523
- await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
526
+ await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
527
+ encoding: 'utf8',
528
+ });
524
529
  stdout.write(`Wrote ${configPath}\n`);
525
530
  }
526
531
 
@@ -533,13 +538,14 @@ async function installCodexMcpConfig(configPath, stdout) {
533
538
  let raw = '';
534
539
  try {
535
540
  raw = await fs.promises.readFile(configPath, 'utf8');
536
- } catch {
537
- raw = '';
541
+ } catch (error) {
542
+ if (!isMissingFileError(error)) {
543
+ throw error;
544
+ }
538
545
  }
539
546
 
540
547
  const updated = upsertCodexServerBlock(raw);
541
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
542
- await fs.promises.writeFile(configPath, updated, 'utf8');
548
+ await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
543
549
  stdout.write(`Wrote ${configPath}\n`);
544
550
  }
545
551
 
@@ -558,10 +564,15 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
558
564
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
559
565
  existing = parsed;
560
566
  } else {
567
+ throw new Error('MCP config root must be a JSON object.');
568
+ }
569
+ } catch (error) {
570
+ if (isMissingFileError(error)) {
561
571
  return false;
562
572
  }
563
- } catch {
564
- return false;
573
+ throw new Error(
574
+ `Cannot remove Browser Bridge from ${configPath}: existing MCP config is not readable valid JSON.`
575
+ );
565
576
  }
566
577
 
567
578
  const shape = getMcpConfigShapeForPath(clientName, configPath);
@@ -584,8 +595,9 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
584
595
  merged[topKey] = updatedBlock;
585
596
  }
586
597
 
587
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
588
- await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
598
+ await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
599
+ encoding: 'utf8',
600
+ });
589
601
  stdout.write(`Removed ${configPath}\n`);
590
602
  return true;
591
603
  }
@@ -599,8 +611,11 @@ async function removeCodexMcpConfig(configPath, stdout) {
599
611
  let raw = '';
600
612
  try {
601
613
  raw = await fs.promises.readFile(configPath, 'utf8');
602
- } catch {
603
- return false;
614
+ } catch (error) {
615
+ if (isMissingFileError(error)) {
616
+ return false;
617
+ }
618
+ throw error;
604
619
  }
605
620
 
606
621
  if (!hasCodexServerBlock(raw)) {
@@ -608,8 +623,7 @@ async function removeCodexMcpConfig(configPath, stdout) {
608
623
  }
609
624
 
610
625
  const updated = removeCodexServerBlock(raw);
611
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
612
- await fs.promises.writeFile(configPath, updated, 'utf8');
626
+ await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
613
627
  stdout.write(`Removed ${configPath}\n`);
614
628
  return true;
615
629
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
 
6
6
  import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/config.js';
7
7
  import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
8
+ import { atomicWriteFile } from './atomic-write.js';
8
9
  import { BridgeClient } from './client.js';
9
10
 
10
11
  const REMOTES_FILENAME = 'remotes.json';
@@ -121,15 +122,13 @@ export async function readRemoteConfig(options = {}) {
121
122
 
122
123
  /**
123
124
  * @param {RemoteConfig} config
124
- * @param {{ configPath?: string, writeFile?: typeof fs.promises.writeFile, mkdir?: typeof fs.promises.mkdir }} [options={}]
125
+ * @param {{ configPath?: string }} [options={}]
125
126
  * @returns {Promise<void>}
126
127
  */
127
128
  export async function writeRemoteConfig(config, options = {}) {
128
129
  const configPath = options.configPath ?? getRemoteConfigPath();
129
- const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
130
- const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
131
- await mkdir(path.dirname(configPath), { recursive: true });
132
- await writeFile(configPath, `${JSON.stringify({ remotes: config.remotes }, null, 2)}\n`, {
130
+ const remotes = config.remotes.map(validateRemoteDestination);
131
+ await atomicWriteFile(configPath, `${JSON.stringify({ remotes }, null, 2)}\n`, {
133
132
  encoding: 'utf8',
134
133
  mode: 0o600,
135
134
  });
@@ -140,15 +139,16 @@ export async function writeRemoteConfig(config, options = {}) {
140
139
  * @returns {Promise<RemoteDestination>}
141
140
  */
142
141
  export async function addRemoteDestination(remote) {
142
+ const validated = validateRemoteDestination(remote);
143
143
  const config = await readRemoteConfig();
144
- const existing = config.remotes.findIndex((entry) => entry.id === remote.id);
144
+ const existing = config.remotes.findIndex((entry) => entry.id === validated.id);
145
145
  if (existing === -1) {
146
- config.remotes.push(remote);
146
+ config.remotes.push(validated);
147
147
  } else {
148
- config.remotes[existing] = remote;
148
+ config.remotes[existing] = validated;
149
149
  }
150
150
  await writeRemoteConfig(config);
151
- return remote;
151
+ return validated;
152
152
  }
153
153
 
154
154
  /**
@@ -180,7 +180,7 @@ export async function listBridgeDestinations() {
180
180
  }
181
181
 
182
182
  /**
183
- * @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean }} ProxyEnableOptions
183
+ * @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean, unsafePlaintext?: boolean }} ProxyEnableOptions
184
184
  */
185
185
 
186
186
  /**
@@ -197,14 +197,92 @@ export async function listBridgeDestinations() {
197
197
  */
198
198
  export function resolveProxyEnableSettings(existing, options, generateToken) {
199
199
  const port = options.port ?? existing?.port ?? DEFAULT_REMOTE_PORT;
200
- const bindHost = options.bindHost ?? existing?.bindHost ?? '0.0.0.0';
200
+ const bindHost = options.bindHost ?? existing?.bindHost ?? '127.0.0.1';
201
201
  if (options.token) {
202
- return { port, bindHost, token: options.token, tokenSource: 'explicit' };
202
+ return {
203
+ port,
204
+ bindHost,
205
+ token: validateAuthToken(options.token),
206
+ tokenSource: 'explicit',
207
+ };
203
208
  }
204
- if (!options.rotateToken && existing?.token) {
205
- return { port, bindHost, token: existing.token, tokenSource: 'existing' };
209
+ const existingToken = normalizeBridgeAuthToken(existing?.token);
210
+ if (!options.rotateToken && existingToken) {
211
+ return { port, bindHost, token: existingToken, tokenSource: 'existing' };
206
212
  }
207
- return { port, bindHost, token: generateToken(), tokenSource: 'generated' };
213
+ return {
214
+ port,
215
+ bindHost,
216
+ token: validateAuthToken(generateToken()),
217
+ tokenSource: 'generated',
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Require an explicit acknowledgement before creating or changing a raw TCP
223
+ * listener that is reachable beyond the local machine. A stored non-loopback
224
+ * host may be reused without breaking an existing installation.
225
+ *
226
+ * @param {{ bindHost: string } | null} existing
227
+ * @param {ProxyEnableOptions} options
228
+ * @param {string} bindHost
229
+ * @returns {void}
230
+ */
231
+ export function assertProxyBindSafety(existing, options, bindHost) {
232
+ if (
233
+ isLoopbackHost(bindHost) ||
234
+ options.unsafePlaintext === true ||
235
+ (options.bindHost === undefined && existing?.bindHost === bindHost)
236
+ ) {
237
+ return;
238
+ }
239
+ throw new Error(
240
+ 'Refusing to bind Browser Bridge raw TCP to a non-loopback host without --unsafe-plaintext. This transport is unencrypted; keep the default 127.0.0.1 bind and use an SSH tunnel instead.'
241
+ );
242
+ }
243
+
244
+ /**
245
+ * @param {string} host
246
+ * @returns {boolean}
247
+ */
248
+ export function isLoopbackHost(host) {
249
+ const normalized = host
250
+ .trim()
251
+ .toLowerCase()
252
+ .replace(/^\[|\]$/gu, '');
253
+ const ipv4Parts = normalized.split('.').map(Number);
254
+ const isIpv4Loopback =
255
+ ipv4Parts.length === 4 &&
256
+ ipv4Parts[0] === 127 &&
257
+ ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
258
+ return (
259
+ normalized === 'localhost' ||
260
+ normalized === '::1' ||
261
+ normalized === '0:0:0:0:0:0:0:1' ||
262
+ isIpv4Loopback
263
+ );
264
+ }
265
+
266
+ /**
267
+ * @param {RemoteDestination} remote
268
+ * @returns {RemoteDestination}
269
+ */
270
+ function validateRemoteDestination(remote) {
271
+ const id = normalizeDestinationId(remote.id);
272
+ const { host, port } = parseRemoteEndpoint(`${remote.host}:${remote.port}`);
273
+ return { id, host, port, token: validateAuthToken(remote.token) };
274
+ }
275
+
276
+ /**
277
+ * @param {unknown} token
278
+ * @returns {string}
279
+ */
280
+ function validateAuthToken(token) {
281
+ const normalized = normalizeBridgeAuthToken(token);
282
+ if (!normalized) {
283
+ throw new Error('Bridge auth token must be a UUID or 32-256 URL-safe characters.');
284
+ }
285
+ return normalized;
208
286
  }
209
287
 
210
288
  /**
@@ -19,6 +19,7 @@ import {
19
19
  } from '../../native-host/src/daemon-process.js';
20
20
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
21
21
  import {
22
+ BridgeError,
22
23
  CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
23
24
  DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
24
25
  getBridgeOperationTimeoutMs,
@@ -102,12 +103,14 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
102
103
  );
103
104
 
104
105
  if (!response.ok) {
105
- throw new Error(response.error.message);
106
+ throw new BridgeError(response.error.code, response.error.message, response.error.details);
106
107
  }
107
108
 
108
109
  const result = /** @type {{ nodes: Array<{ elementRef: string }> }} */ (response.result);
109
110
  if (!result.nodes || result.nodes.length === 0) {
110
- throw new Error(`No element found for selector "${refOrSelector}".`);
111
+ throw new BridgeError('INVALID_REQUEST', `No element found for selector "${refOrSelector}".`, {
112
+ selector: refOrSelector,
113
+ });
111
114
  }
112
115
  return result.nodes[0].elementRef;
113
116
  }
@@ -4,11 +4,13 @@
4
4
  import path from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
+ import { tryStartMcpProcessControl } from './lifecycle.js';
7
8
  import { startBridgeMcpServer } from './server.js';
8
9
 
9
10
  /**
10
11
  * @typedef {{
11
12
  * start?: () => Promise<void>,
13
+ * startControl?: typeof tryStartMcpProcessControl,
12
14
  * argv?: string[],
13
15
  * stdout?: { write: (chunk: string) => unknown },
14
16
  * stderr?: { write: (chunk: string) => unknown },
@@ -33,6 +35,7 @@ const MCP_USAGE = [
33
35
  export async function runBridgeMcpCli(options = {}) {
34
36
  const {
35
37
  start = startBridgeMcpServer,
38
+ startControl = tryStartMcpProcessControl,
36
39
  argv = process.argv.slice(2),
37
40
  stdout = process.stdout,
38
41
  stderr = process.stderr,
@@ -45,7 +48,13 @@ export async function runBridgeMcpCli(options = {}) {
45
48
  }
46
49
 
47
50
  try {
48
- await start();
51
+ const control = await startControl();
52
+ try {
53
+ await start();
54
+ } catch (error) {
55
+ await control?.dispose();
56
+ throw error;
57
+ }
49
58
  return 0;
50
59
  } catch (error) {
51
60
  const message = error instanceof Error ? error.stack || error.message : String(error);
@@ -103,7 +103,7 @@ function createGuidePrompt() {
103
103
  '2. In permission-ask hosts, use `browser_call` as the default tool so the user can approve one BBX MCP tool instead of separate tools for status, page, DOM, input, and patches.',
104
104
  '3. Start with `browser_call` method `health.ping`. If access is disabled, call `browser_call` method `access.request` once, ask the user to click Enable, then retry once.',
105
105
  '4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, and `styles.get_computed`. Use `browser_batch` for parallel reads.',
106
- '5. `browser_call` accepts explicit method limits, not `budgetPreset`; keep limits tight and widen only when results are truncated.',
106
+ '5. `browser_call` accepts `budgetPreset`; explicit method limits override preset values. Keep limits tight and widen only when results are truncated.',
107
107
  '6. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
108
108
  '7. Escalate to screenshots, accessibility tree, `page.evaluate`, viewport resize, or CDP only when structured reads cannot answer.',
109
109
  '8. Use `patch.apply_styles` or `patch.apply_dom` for temporary experiments and rollback before finishing unless the user asks to keep patches.',
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
 
3
+ import { BridgeError } from '../../protocol/src/index.js';
3
4
  import {
4
5
  createToolResult,
5
6
  getToolTokenBudget,
@@ -109,7 +110,7 @@ export async function handleCaptureTool(args) {
109
110
  const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
110
111
  tabId: requestedTabId,
111
112
  source: REQUEST_SOURCE,
112
- tokenBudget: getToolTokenBudget(args),
113
+ tokenBudget: entry.method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
113
114
  });
114
115
  if (!response.ok) {
115
116
  return summarizeToolResponse(response, entry.method);
@@ -127,15 +128,15 @@ export async function handleCaptureTool(args) {
127
128
  * @param {BridgeMethod} method
128
129
  * @returns {ToolResult}
129
130
  */
130
- function createScreenshotResult(response, method) {
131
+ export function createScreenshotResult(response, method) {
131
132
  const result = toRecord(response.result);
132
133
  if (typeof result.image !== 'string') {
133
- return summarizeToolError(`${method} returned no image data.`);
134
+ return summarizeToolError(new Error(`${method} returned no image data.`));
134
135
  }
135
136
 
136
137
  const image = normalizeBase64Image(result.image);
137
138
  if (!image) {
138
- return summarizeToolError(`${method} returned invalid base64 image data.`);
139
+ return summarizeToolError(new Error(`${method} returned invalid base64 image data.`));
139
140
  }
140
141
 
141
142
  const rect = boundedRect(result.rect);
@@ -320,7 +321,9 @@ export async function handleInputTool(args) {
320
321
  const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
321
322
  const elementTarget = () => {
322
323
  const target = createInputTarget(args.elementRef, args.selector);
323
- if (!target) throw new Error('Provide either elementRef or selector.');
324
+ if (!target) {
325
+ throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
326
+ }
324
327
  return target;
325
328
  };
326
329
 
@@ -104,7 +104,8 @@ export async function handleDomTool(args) {
104
104
  return summarizeToolError('role is required for dom.find_by_role.');
105
105
  }
106
106
  if (args.action === 'query' || args.action === 'accessibility_tree') {
107
- const inferred = inferBudgetFromSelector(args);
107
+ const inferred =
108
+ args.action === 'accessibility_tree' ? 'normal' : inferBudgetFromSelector(args);
108
109
  const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
109
110
  return dispatchToolAction(DOM_ACTIONS, applyTreeBudgetPreset(withBudget), 'DOM');
110
111
  }
@@ -183,7 +184,6 @@ export const PATCH_ACTIONS = {
183
184
  addClass: 'add_class',
184
185
  removeClass: 'remove_class',
185
186
  setTextContent: 'set_text',
186
- setProperty: 'set_attribute',
187
187
  };
188
188
  const normalizedOperation = opMap[operation] || operation;
189
189
  const value =
@@ -6,6 +6,7 @@ import {
6
6
  createToolResult,
7
7
  getBridgeDestinations,
8
8
  getToolTokenBudget,
9
+ requestBridgeWithRetry,
9
10
  summarizeToolError,
10
11
  } from './handlers-utils.js';
11
12
 
@@ -27,15 +28,23 @@ export async function handleTabsTool(args) {
27
28
  }
28
29
  const results = await Promise.all(
29
30
  destinations.map(async (destination) => {
30
- const client = await createBridgeClientForDestination(
31
- destination.local ? null : destination.id
32
- );
31
+ /** @type {import('../../agent-client/src/client.js').BridgeClient | null} */
32
+ let client = null;
33
33
  try {
34
+ client = await createBridgeClientForDestination(
35
+ destination.local ? null : destination.id
36
+ );
34
37
  await client.connect();
35
- const response = await client.request({
36
- method: 'tabs.list',
37
- meta: { source: 'mcp' },
38
- });
38
+ const response = await requestBridgeWithRetry(
39
+ client,
40
+ 'tabs.list',
41
+ {},
42
+ {
43
+ tabId: null,
44
+ source: 'mcp',
45
+ tokenBudget: null,
46
+ }
47
+ );
39
48
  if (!response.ok) {
40
49
  return {
41
50
  destinationId: destination.id,
@@ -58,7 +67,9 @@ export async function handleTabsTool(args) {
58
67
  tabs: [],
59
68
  };
60
69
  } finally {
61
- await client.close();
70
+ if (client) {
71
+ await client.close().catch(() => {});
72
+ }
62
73
  }
63
74
  })
64
75
  );
@@ -73,7 +84,7 @@ export async function handleTabsTool(args) {
73
84
  tabs,
74
85
  destinations: results,
75
86
  },
76
- false
87
+ failures.length === results.length
77
88
  );
78
89
  }
79
90
  if (args.action === 'create') {