@browserbridge/bbx 1.11.0 → 1.11.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/SECURITY.md CHANGED
@@ -52,8 +52,15 @@ Out of scope:
52
52
 
53
53
  - By default the daemon listens on a user-owned Unix socket (mode `0700` directory) or,
54
54
  on Windows, a named pipe / localhost TCP.
55
- - Any TCP listener requires a random 256-bit auth token stored with mode `0600`;
56
- registration without it is rejected and the comparison is constant-time.
55
+ - Any TCP listener requires random 256-bit credentials stored with mode `0600`.
56
+ Agents and the Chrome native host use distinct credentials, so a remote agent
57
+ token cannot register an extension-role socket. Registration without the
58
+ role-appropriate credential is rejected and comparison is constant-time.
59
+ - Setup mutation is accepted only from the local socket or authenticated native
60
+ host. A TCP agent remains non-local even when its peer address is loopback,
61
+ preventing SSH port forwarding from turning remote access into local control.
62
+ - Daemon connections, registration time, malformed input, pending requests, and
63
+ per-client pending requests are bounded to limit resource exhaustion.
57
64
  - Proxy mode (LAN exposure) is opt-in via `bbx proxy enable` and always provisions a
58
65
  token; an invalid `bindHost` in the config rejects the config rather than widening
59
66
  the bind address.
@@ -62,3 +69,8 @@ Out of scope:
62
69
  - Ordinary Web Storage reads return key/presence metadata only. The separate
63
70
  `sensitive.read` method requires one exact key, is excluded from batches and
64
71
  automatic retries, and returns a value whole or fails atomically.
72
+ - Fetch/XHR and console capture run in the inspected page's main JavaScript
73
+ world. Results are therefore marked `integrity: "untrusted"`, copied through
74
+ strict bounded schemas, and never treated as daemon or extension authority.
75
+ - Request interception rules are bounded per tab; patterns, bodies, and headers
76
+ have protocol-level size limits and URL matchers are compiled once per rule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -80,7 +80,7 @@
80
80
  },
81
81
  "devDependencies": {
82
82
  "@modelcontextprotocol/client": "^2.0.0",
83
- "@types/chrome": "^0.2.5",
83
+ "@types/chrome": "^0.2.6",
84
84
  "@types/node": "^26.2.0",
85
85
  "ajv": "^8.20.0",
86
86
  "ajv-formats": "^3.0.1",
@@ -104,16 +104,37 @@ function shouldAutoRestartDaemonOnVersionMismatch(env = process.env) {
104
104
  /**
105
105
  * @param {net.Socket} socket
106
106
  * @param {string} line
107
+ * @param {AbortSignal} signal
107
108
  * @returns {Promise<void>}
108
109
  */
109
- async function writeSocketLine(socket, line) {
110
+ async function writeSocketLine(socket, line, signal) {
111
+ signal.throwIfAborted();
110
112
  if (!socket.write(line)) {
111
- await Promise.race([
112
- once(socket, 'drain'),
113
- once(socket, 'close').then(() => {
114
- throw new Error('Bridge socket closed while writing.');
115
- }),
116
- ]);
113
+ await new Promise((resolve, reject) => {
114
+ const cleanup = () => {
115
+ socket.off('drain', onDrain);
116
+ socket.off('error', onError);
117
+ socket.off('close', onClose);
118
+ signal.removeEventListener('abort', onAbort);
119
+ };
120
+ const onDrain = () => {
121
+ cleanup();
122
+ resolve(undefined);
123
+ };
124
+ /** @param {unknown} error */
125
+ const onError = (error) => {
126
+ cleanup();
127
+ reject(error);
128
+ };
129
+ const onClose = () => onError(new Error('Bridge socket closed while writing.'));
130
+ const onAbort = () => onError(signal.reason);
131
+ socket.once('drain', onDrain);
132
+ socket.once('error', onError);
133
+ socket.once('close', onClose);
134
+ signal.addEventListener('abort', onAbort, { once: true });
135
+ if (signal.aborted) onAbort();
136
+ else if (socket.destroyed) onClose();
137
+ });
117
138
  }
118
139
  }
119
140
 
@@ -176,12 +197,31 @@ export class BridgeClient extends EventEmitter {
176
197
  this.socket = socket;
177
198
  try {
178
199
  await new Promise((resolve, reject) => {
179
- socket.once('connect', resolve);
180
- socket.once('error', reject);
200
+ const cleanup = () => {
201
+ socket.off('connect', onConnect);
202
+ socket.off('error', onError);
203
+ socket.off('close', onClose);
204
+ };
205
+ const onConnect = () => {
206
+ cleanup();
207
+ resolve(undefined);
208
+ };
209
+ /** @param {Error} error */
210
+ const onError = (error) => {
211
+ cleanup();
212
+ reject(error);
213
+ };
214
+ const onClose = () => onError(new Error('Bridge socket closed while connecting.'));
215
+ socket.once('connect', onConnect);
216
+ socket.once('error', onError);
217
+ socket.once('close', onClose);
181
218
  });
219
+ if (this.socket !== socket || socket.destroyed) {
220
+ throw new Error('Bridge socket closed while connecting.');
221
+ }
182
222
  } catch (error) {
183
223
  socket.destroy();
184
- this.socket = null;
224
+ if (this.socket === socket) this.socket = null;
185
225
  throw error;
186
226
  }
187
227
 
@@ -198,6 +238,7 @@ export class BridgeClient extends EventEmitter {
198
238
  });
199
239
 
200
240
  parseJsonLines(socket, (raw) => {
241
+ if (this.socket !== socket) return;
201
242
  const message = /** @type {ClientMessage} */ (raw);
202
243
  if (message.type === 'registered') {
203
244
  const pending = this.waiting.get('registered');
@@ -231,6 +272,7 @@ export class BridgeClient extends EventEmitter {
231
272
  });
232
273
 
233
274
  socket.on('close', () => {
275
+ if (this.socket !== socket) return;
234
276
  this.connected = false;
235
277
  this.socket = null;
236
278
  this.rejectAllPending(new Error('Bridge socket closed.'));
@@ -240,35 +282,50 @@ export class BridgeClient extends EventEmitter {
240
282
  });
241
283
 
242
284
  socket.on('error', (error) => {
285
+ if (this.socket !== socket) return;
243
286
  this.rejectAllPending(error);
244
287
  // 'close' fires after 'error'; reconnect is triggered there.
245
288
  });
246
289
 
247
- const authToken =
248
- this.authToken === undefined
249
- ? this.transport.type === 'tcp'
250
- ? await readBridgeAuthToken()
251
- : null
252
- : normalizeBridgeAuthToken(this.authToken);
290
+ const registrationWrite = new AbortController();
253
291
  try {
254
- await writeSocketLine(
255
- socket,
256
- `${JSON.stringify({
257
- type: 'register',
258
- role: 'agent',
259
- clientId: this.clientId,
260
- ...(authToken ? { authToken } : {}),
261
- })}\n`
262
- );
292
+ const writePromise = (async () => {
293
+ const authToken =
294
+ this.authToken === undefined
295
+ ? this.transport.type === 'tcp'
296
+ ? await readBridgeAuthToken()
297
+ : null
298
+ : normalizeBridgeAuthToken(this.authToken);
299
+ await writeSocketLine(
300
+ socket,
301
+ `${JSON.stringify({
302
+ type: 'register',
303
+ role: 'agent',
304
+ clientId: this.clientId,
305
+ ...(authToken ? { authToken } : {}),
306
+ })}\n`,
307
+ registrationWrite.signal
308
+ );
309
+ })();
310
+ await Promise.race([registrationPromise, writePromise.then(() => registrationPromise)]);
311
+ if (this.socket !== socket || socket.destroyed) {
312
+ throw new Error('Bridge socket closed while registering.');
313
+ }
263
314
  } catch (error) {
264
- const pending = this.waiting.get('registered');
265
- if (pending) {
266
- clearTimeout(pending.timeoutId);
267
- this.waiting.delete('registered');
315
+ if (this.socket === socket) {
316
+ const pending = this.waiting.get('registered');
317
+ if (pending) {
318
+ clearTimeout(pending.timeoutId);
319
+ this.waiting.delete('registered');
320
+ }
321
+ this.connected = false;
322
+ this.socket = null;
268
323
  }
324
+ socket.destroy();
269
325
  throw error;
326
+ } finally {
327
+ registrationWrite.abort();
270
328
  }
271
- await registrationPromise;
272
329
 
273
330
  this.protocolCompatibility = null;
274
331
  this.protocolWarning = null;
@@ -553,18 +610,25 @@ export class BridgeClient extends EventEmitter {
553
610
  });
554
611
  });
555
612
 
613
+ const requestWrite = new AbortController();
556
614
  try {
557
- await writeSocketLine(this.socket, `${JSON.stringify({ type: 'agent.request', request })}\n`);
558
- } catch (error) {
615
+ const writePromise = writeSocketLine(
616
+ this.socket,
617
+ `${JSON.stringify({ type: 'agent.request', request })}\n`,
618
+ requestWrite.signal
619
+ );
620
+ const response = /** @type {BridgeResponse} */ (
621
+ await Promise.race([responsePromise, writePromise.then(() => responsePromise)])
622
+ );
623
+ return this.attachProtocolWarning(response);
624
+ } finally {
625
+ requestWrite.abort();
559
626
  const pending = this.waiting.get(request.id);
560
627
  if (pending) {
561
628
  clearTimeout(pending.timeoutId);
562
629
  this.waiting.delete(request.id);
563
630
  }
564
- throw error;
565
631
  }
566
- const response = /** @type {BridgeResponse} */ (await responsePromise);
567
- return this.attachProtocolWarning(response);
568
632
  }
569
633
 
570
634
  /**
@@ -611,11 +675,19 @@ export class BridgeClient extends EventEmitter {
611
675
  */
612
676
  async close() {
613
677
  this.autoReconnect = false; // prevent reconnect on intentional close
614
- if (!this.socket) {
678
+ const socket = this.socket;
679
+ if (!socket) {
680
+ return;
681
+ }
682
+ if (socket.connecting || !this.connected || socket.destroyed || socket.writableNeedDrain) {
683
+ this.socket = null;
684
+ this.connected = false;
685
+ this.rejectAllPending(new Error('Bridge socket closed.'));
686
+ socket.destroy();
615
687
  return;
616
688
  }
617
689
  await new Promise((resolve) => {
618
- this.socket?.end(() => resolve(undefined));
690
+ socket.end(() => resolve(undefined));
619
691
  });
620
692
  }
621
693
 
@@ -697,6 +769,7 @@ export class BridgeClient extends EventEmitter {
697
769
  const previousAutoReconnect = this.autoReconnect;
698
770
  this.autoReconnect = false;
699
771
  this.connected = false;
772
+ this.rejectAllPending(new Error('Bridge socket closed.'));
700
773
  this.socket = null;
701
774
 
702
775
  if (!socket.destroyed) {
@@ -204,8 +204,8 @@ function withRequestMeta(source, mcpEra, tokenBudget, automaticRetry) {
204
204
  */
205
205
  export async function withBridgeClient(callback, options) {
206
206
  const client = new BridgeClient(await applyConfiguredAutoUpdate(options));
207
- await ensureClientConnected(client);
208
207
  try {
208
+ await ensureClientConnected(client);
209
209
  return await callback(client);
210
210
  } finally {
211
211
  await client.close();
@@ -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'}). 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.`,
156
+ `Captured screenshot artifact (${artifact.byteLength} bytes, expires ${artifact.expiresAt ?? 'soon'}). Read it in chunks with browser_call method "artifact.read", paging offset until nextOffset is null, then delete it with browser_call method "artifact.delete" when done.`,
157
157
  {
158
158
  ok: true,
159
159
  method,
@@ -1,6 +1,5 @@
1
1
  // @ts-check
2
2
 
3
- import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
4
3
  import {
5
4
  callBridgeTool,
6
5
  createToolResult,
@@ -8,6 +7,7 @@ import {
8
7
  getToolTokenBudget,
9
8
  requestBridgeWithRetry,
10
9
  summarizeToolError,
10
+ withMcpRequestClient,
11
11
  } from './handlers-utils.js';
12
12
 
13
13
  /** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
@@ -28,23 +28,20 @@ export async function handleTabsTool(args) {
28
28
  }
29
29
  const results = await Promise.all(
30
30
  destinations.map(async (destination) => {
31
- /** @type {import('../../agent-client/src/client.js').BridgeClient | null} */
32
- let client = null;
33
31
  try {
34
- client = await createBridgeClientForDestination(
35
- destination.local ? null : destination.id,
36
- { checkProtocolOnConnect: false }
37
- );
38
- await client.connect();
39
- const response = await requestBridgeWithRetry(
40
- client,
41
- 'tabs.list',
42
- {},
43
- {
44
- tabId: null,
45
- source: 'mcp',
46
- tokenBudget: null,
47
- }
32
+ const response = await withMcpRequestClient(
33
+ (client) =>
34
+ requestBridgeWithRetry(
35
+ client,
36
+ 'tabs.list',
37
+ {},
38
+ {
39
+ tabId: null,
40
+ source: 'mcp',
41
+ tokenBudget: null,
42
+ }
43
+ ),
44
+ { destinationId: destination.local ? null : destination.id }
48
45
  );
49
46
  if (!response.ok) {
50
47
  return {
@@ -67,10 +64,6 @@ export async function handleTabsTool(args) {
67
64
  error: { message: error instanceof Error ? error.message : String(error) },
68
65
  tabs: [],
69
66
  };
70
- } finally {
71
- if (client) {
72
- await client.close().catch(() => {});
73
- }
74
67
  }
75
68
  })
76
69
  );
@@ -13,7 +13,6 @@ import {
13
13
  MAX_BATCH_CONCURRENCY,
14
14
  METHOD_SET,
15
15
  } from '../../protocol/src/index.js';
16
- import { createBridgeClientForDestination } from '../../agent-client/src/remotes.js';
17
16
  import {
18
17
  annotateBridgeSummary,
19
18
  applyLimitBudgetPreset,
@@ -31,6 +30,8 @@ import {
31
30
  summarizeToolError,
32
31
  summarizeToolResponse,
33
32
  withToolClient,
33
+ withMcpRequestClient,
34
+ throwIfMcpRequestCancelled,
34
35
  REQUEST_SOURCE,
35
36
  } from './handlers-utils.js';
36
37
  import { createScreenshotResult } from './handlers-capture.js';
@@ -383,33 +384,23 @@ export async function handleBatchTool(args) {
383
384
  const tokenBudget = getToolTokenBudget(call);
384
385
  const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
385
386
  const startTime = Date.now();
386
- /** @type {BridgeClient | null} */
387
- let callClient = null;
388
387
  /** @type {import('../../protocol/src/types.js').BridgeResponse | null} */
389
388
  let response = null;
390
389
  /** @type {unknown} */
391
390
  let callError = null;
392
391
  try {
393
- callClient = await createBridgeClientForDestination(destinationId, {
394
- checkProtocolOnConnect: false,
395
- });
396
- await callClient.connect();
397
392
  const params = applyMethodBudgetPreset(method, call.params || {}, call.budgetPreset);
398
- response = await requestBridgeWithRetry(callClient, method, params, {
399
- tabId,
400
- source: REQUEST_SOURCE,
401
- tokenBudget,
402
- });
393
+ response = await withMcpRequestClient(
394
+ (client) =>
395
+ requestBridgeWithRetry(client, method, params, {
396
+ tabId,
397
+ source: REQUEST_SOURCE,
398
+ tokenBudget,
399
+ }),
400
+ { destinationId }
401
+ );
403
402
  } catch (error) {
404
403
  callError = error;
405
- } finally {
406
- if (callClient) {
407
- try {
408
- await callClient.close();
409
- } catch (error) {
410
- callError ??= error;
411
- }
412
- }
413
404
  }
414
405
  if (callError || !response) {
415
406
  return {
@@ -489,6 +480,7 @@ async function mapWithConcurrency(values, concurrency, callback) {
489
480
  let nextIndex = 0;
490
481
  const worker = async () => {
491
482
  while (nextIndex < values.length) {
483
+ throwIfMcpRequestCancelled();
492
484
  const index = nextIndex;
493
485
  nextIndex += 1;
494
486
  results[index] = await callback(values[index], index);
@@ -537,6 +529,9 @@ export async function handleRawCallTool(args) {
537
529
  if (method === 'sensitive.read') {
538
530
  return createSensitiveReadResult(response);
539
531
  }
532
+ if (method === 'artifact.read') {
533
+ return createArtifactReadResult(response);
534
+ }
540
535
  return summarizeToolResponse(response, method, params);
541
536
  },
542
537
  { destinationId: args.destinationId ?? null }
@@ -729,6 +724,7 @@ export async function handleInvestigateTool(args) {
729
724
  const stepResults = [];
730
725
 
731
726
  for (const step of scope.steps) {
727
+ throwIfMcpRequestCancelled();
732
728
  const startTime = Date.now();
733
729
  try {
734
730
  const response = await requestBridgeWithRetry(client, step.method, step.params(args), {