@browserbridge/bbx 1.10.0 → 1.11.1

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.
@@ -44,7 +44,12 @@ import {
44
44
  getSocketPath,
45
45
  } from './config.js';
46
46
  import { ArtifactStore } from './artifact-store.js';
47
- import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
47
+ import {
48
+ bridgeAuthTokensEqual,
49
+ ensureBridgeAuthToken,
50
+ ensureBridgeExtensionAuthToken,
51
+ readBridgeAuthToken,
52
+ } from './auth-token.js';
48
53
  import { normalizeDaemonLogger } from './daemon-logger.js';
49
54
  import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
50
55
  import { writeJsonLine } from './framing.js';
@@ -59,8 +64,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
59
64
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
60
65
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
61
66
  /** @typedef {'agent' | 'extension'} SocketRole */
62
- /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
63
- /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
67
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __localControl?: boolean, __registrationTimeoutId?: NodeJS.Timeout, __invalidLineCount?: number, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
68
+ /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, mcpEra?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
64
69
  /**
65
70
  * @typedef {{
66
71
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -123,6 +128,13 @@ const DIAGNOSTIC_METHODS = new Set([
123
128
  'setup.get_status',
124
129
  'setup.install',
125
130
  ]);
131
+ export const DAEMON_REGISTRATION_TIMEOUT_MS = 5_000;
132
+ export const MAX_DAEMON_CONNECTIONS = 64;
133
+ export const MAX_DAEMON_PENDING_REQUESTS = 128;
134
+ export const MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT = 16;
135
+ export const MAX_DAEMON_INVALID_LINES = 3;
136
+ const MAX_CLIENT_ID_LENGTH = 128;
137
+ const MAX_ROUTING_LABEL_LENGTH = 128;
126
138
 
127
139
  /**
128
140
  * @param {unknown} value
@@ -134,6 +146,42 @@ function asHealthRecord(value) {
134
146
  : null;
135
147
  }
136
148
 
149
+ /**
150
+ * @param {unknown} value
151
+ * @returns {string | undefined}
152
+ */
153
+ function normalizeRoutingLabel(value) {
154
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ROUTING_LABEL_LENGTH) {
155
+ return undefined;
156
+ }
157
+ return hasAsciiControlCharacters(value) ? undefined : value;
158
+ }
159
+
160
+ /** @param {string} value @returns {boolean} */
161
+ function hasAsciiControlCharacters(value) {
162
+ for (let index = 0; index < value.length; index += 1) {
163
+ const code = value.charCodeAt(index);
164
+ if (code < 32 || code === 127) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * @param {unknown} value
171
+ * @returns {string | null}
172
+ */
173
+ function normalizeClientId(value) {
174
+ if (value == null || value === '') return randomUUID();
175
+ if (
176
+ typeof value !== 'string' ||
177
+ value.length > MAX_CLIENT_ID_LENGTH ||
178
+ !/^[A-Za-z0-9_.:-]+$/u.test(value)
179
+ ) {
180
+ return null;
181
+ }
182
+ return value;
183
+ }
184
+
137
185
  /**
138
186
  * @param {unknown} value
139
187
  * @returns {number | undefined}
@@ -383,6 +431,7 @@ export class BridgeDaemon {
383
431
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
384
432
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
385
433
  * authToken?: string | null,
434
+ * extensionAuthToken?: string | null,
386
435
  * artifactStore?: ArtifactStore
387
436
  * }} [options={}]
388
437
  */
@@ -394,6 +443,7 @@ export class BridgeDaemon {
394
443
  setupInstaller = installSetupTarget,
395
444
  logger = undefined,
396
445
  authToken = undefined,
446
+ extensionAuthToken = undefined,
397
447
  artifactStore = new ArtifactStore(getArtifactStorePath()),
398
448
  } = {}) {
399
449
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -437,6 +487,8 @@ export class BridgeDaemon {
437
487
  this.requestStartTimes = new Map();
438
488
  /** @type {string | null | undefined} */
439
489
  this.authToken = authToken;
490
+ /** @type {string | null | undefined} */
491
+ this.extensionAuthToken = extensionAuthToken;
440
492
  this.artifactStore = artifactStore;
441
493
  this.artifactStoreInitialized = false;
442
494
  this.domBaselines = new DomBaselineOwnerRegistry({
@@ -580,26 +632,32 @@ export class BridgeDaemon {
580
632
  return;
581
633
  }
582
634
 
583
- if (this.isAuthRequired() && !bridgeAuthTokensEqual(message.authToken, this.authToken)) {
584
- this.logger.error('socket registration rejected', { role: message.role ?? null });
635
+ if (message.role !== 'extension' && message.role !== 'agent') {
585
636
  void writeJsonLine(socket, {
586
637
  type: 'registration_failed',
587
638
  error: {
588
- code: ERROR_CODES.ACCESS_DENIED,
589
- message: 'Bridge daemon authentication failed.',
639
+ code: ERROR_CODES.INVALID_REQUEST,
640
+ message: 'Socket role must be "agent" or "extension".',
590
641
  },
591
- }).finally(() => socket.destroy());
642
+ });
592
643
  return;
593
644
  }
594
645
 
595
- if (message.role !== 'extension' && message.role !== 'agent') {
646
+ const expectedToken =
647
+ message.role === 'extension'
648
+ ? this.extensionAuthToken
649
+ : this.isAuthRequired()
650
+ ? this.authToken
651
+ : null;
652
+ if (expectedToken && !bridgeAuthTokensEqual(message.authToken, expectedToken)) {
653
+ this.logger.error('socket registration rejected', { role: message.role ?? null });
596
654
  void writeJsonLine(socket, {
597
655
  type: 'registration_failed',
598
656
  error: {
599
- code: ERROR_CODES.INVALID_REQUEST,
600
- message: 'Socket role must be "agent" or "extension".',
657
+ code: ERROR_CODES.ACCESS_DENIED,
658
+ message: 'Bridge daemon authentication failed.',
601
659
  },
602
- });
660
+ }).finally(() => socket.destroy());
603
661
  return;
604
662
  }
605
663
 
@@ -609,13 +667,16 @@ export class BridgeDaemon {
609
667
  configurable: false,
610
668
  writable: false,
611
669
  });
670
+ if (socket.__registrationTimeoutId) {
671
+ clearTimeout(socket.__registrationTimeoutId);
672
+ socket.__registrationTimeoutId = undefined;
673
+ }
612
674
  if (message.role === 'extension') {
675
+ socket.__localControl = true;
613
676
  const extensionId = randomUUID();
614
677
  socket.__extensionId = extensionId;
615
- socket.__browserName =
616
- typeof message.browserName === 'string' ? message.browserName : undefined;
617
- socket.__profileLabel =
618
- typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
678
+ socket.__browserName = normalizeRoutingLabel(message.browserName);
679
+ socket.__profileLabel = normalizeRoutingLabel(message.profileLabel);
619
680
  socket.__browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
620
681
  socket.__lastActiveAt = Date.now();
621
682
  this.extensionSockets.set(extensionId, socket);
@@ -630,7 +691,18 @@ export class BridgeDaemon {
630
691
  }
631
692
 
632
693
  if (message.role === 'agent') {
633
- const clientId = message.clientId || randomUUID();
694
+ socket.__localControl = this.transport.type === 'socket';
695
+ const clientId = normalizeClientId(message.clientId);
696
+ if (!clientId) {
697
+ void writeJsonLine(socket, {
698
+ type: 'registration_failed',
699
+ error: {
700
+ code: ERROR_CODES.INVALID_REQUEST,
701
+ message: 'Agent clientId is invalid.',
702
+ },
703
+ }).finally(() => socket.destroy());
704
+ return;
705
+ }
634
706
  this.agentSockets.set(clientId, socket);
635
707
  socket.__clientId = clientId;
636
708
  this.logger.info('agent registered', { clientId });
@@ -649,6 +721,10 @@ export class BridgeDaemon {
649
721
  if (this.authToken === undefined) {
650
722
  this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
651
723
  }
724
+ if (this.extensionAuthToken === undefined) {
725
+ this.extensionAuthToken =
726
+ this.transport.type === 'tcp' ? await ensureBridgeExtensionAuthToken() : null;
727
+ }
652
728
 
653
729
  if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
654
730
  const socketDir = path.dirname(this.socketPath);
@@ -677,6 +753,14 @@ export class BridgeDaemon {
677
753
 
678
754
  this.server = net.createServer((socket) => {
679
755
  const typedSocket = /** @type {ClientSocket} */ (socket);
756
+ typedSocket.__invalidLineCount = 0;
757
+ typedSocket.__registrationTimeoutId = setTimeout(() => {
758
+ if (!typedSocket.__role) {
759
+ this.logger.warn('unregistered socket timed out');
760
+ typedSocket.destroy();
761
+ }
762
+ }, DAEMON_REGISTRATION_TIMEOUT_MS);
763
+ typedSocket.__registrationTimeoutId.unref?.();
680
764
  typedSocket.on('error', (err) => {
681
765
  this.logger.error('socket error', { message: err.message });
682
766
  });
@@ -694,10 +778,18 @@ export class BridgeDaemon {
694
778
  onProtocolError: (error) => {
695
779
  this.logger.error('socket protocol error', { message: error.message });
696
780
  },
781
+ onInvalidLine: () => {
782
+ typedSocket.__invalidLineCount = (typedSocket.__invalidLineCount ?? 0) + 1;
783
+ if (typedSocket.__invalidLineCount >= MAX_DAEMON_INVALID_LINES) {
784
+ this.logger.warn('socket sent too many malformed JSON lines');
785
+ typedSocket.destroy();
786
+ }
787
+ },
697
788
  }
698
789
  );
699
790
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
700
791
  });
792
+ this.server.maxConnections = MAX_DAEMON_CONNECTIONS;
701
793
 
702
794
  const server = this.server;
703
795
  await new Promise((resolve, reject) => {
@@ -1076,7 +1168,7 @@ export class BridgeDaemon {
1076
1168
  }
1077
1169
 
1078
1170
  if (request.method === 'setup.install') {
1079
- if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
1171
+ if (!socket.__localControl) {
1080
1172
  const response = createFailure(
1081
1173
  request.id,
1082
1174
  ERROR_CODES.ACCESS_DENIED,
@@ -1115,6 +1207,26 @@ export class BridgeDaemon {
1115
1207
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
1116
1208
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
1117
1209
 
1210
+ const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
1211
+ if (
1212
+ this.pendingRequests.size >= MAX_DAEMON_PENDING_REQUESTS ||
1213
+ ownerPendingCount >= MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT
1214
+ ) {
1215
+ const response = createFailure(
1216
+ request.id,
1217
+ ERROR_CODES.INVALID_REQUEST,
1218
+ 'Daemon pending-request limit reached. Wait for existing requests to finish.',
1219
+ {
1220
+ maxPendingRequests: MAX_DAEMON_PENDING_REQUESTS,
1221
+ maxPendingRequestsPerClient: MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT,
1222
+ },
1223
+ { method: request.method }
1224
+ );
1225
+ await writeJsonLine(socket, { type: 'agent.response', response });
1226
+ this.recordDaemonRequestOutcome(request, false);
1227
+ return;
1228
+ }
1229
+
1118
1230
  const baselineId =
1119
1231
  typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1120
1232
  const usesBaselineOwner =
@@ -1165,6 +1277,7 @@ export class BridgeDaemon {
1165
1277
  method: request.method,
1166
1278
  protocolVersion: request.meta?.protocol_version,
1167
1279
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
1280
+ mcpEra: typeof request.meta?.mcp_era === 'string' ? request.meta.mcp_era : '',
1168
1281
  baselineId,
1169
1282
  automaticMcpRetry: isAutomaticMcpRetry(request.meta),
1170
1283
  targets: new Set([target]),
@@ -1221,23 +1334,6 @@ export class BridgeDaemon {
1221
1334
  }
1222
1335
  }
1223
1336
 
1224
- /**
1225
- * @param {ClientSocket} socket
1226
- * @returns {boolean}
1227
- */
1228
- isLocalSocket(socket) {
1229
- if (this.transport.type === 'socket') {
1230
- return true;
1231
- }
1232
- const address = socket.remoteAddress ?? '';
1233
- return (
1234
- address === '127.0.0.1' ||
1235
- address === '::1' ||
1236
- address === 'localhost' ||
1237
- address === '::ffff:127.0.0.1'
1238
- );
1239
- }
1240
-
1241
1337
  /**
1242
1338
  * Select from a snapshot so routing neither mutates nor observes mutations to
1243
1339
  * the live extension map while candidates are ordered.
@@ -1347,13 +1443,15 @@ export class BridgeDaemon {
1347
1443
  */
1348
1444
  handleExtensionIdentity(socket, message) {
1349
1445
  let changed = false;
1350
- if (typeof message.browserName === 'string') {
1351
- changed = changed || socket.__browserName !== message.browserName;
1352
- socket.__browserName = message.browserName;
1446
+ const browserName = normalizeRoutingLabel(message.browserName);
1447
+ if (browserName) {
1448
+ changed = changed || socket.__browserName !== browserName;
1449
+ socket.__browserName = browserName;
1353
1450
  }
1354
- if (typeof message.profileLabel === 'string') {
1355
- changed = changed || socket.__profileLabel !== message.profileLabel;
1356
- socket.__profileLabel = message.profileLabel;
1451
+ const profileLabel = normalizeRoutingLabel(message.profileLabel);
1452
+ if (profileLabel) {
1453
+ changed = changed || socket.__profileLabel !== profileLabel;
1454
+ socket.__profileLabel = profileLabel;
1357
1455
  }
1358
1456
  if (typeof message.browserExtensionId === 'string') {
1359
1457
  const browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
@@ -1456,12 +1554,11 @@ export class BridgeDaemon {
1456
1554
 
1457
1555
  /**
1458
1556
  * @param {ClientSocket} socket
1459
- * @param {DaemonMessage} message
1557
+ * @param {DaemonMessage} _message
1460
1558
  * @returns {void}
1461
1559
  */
1462
- handleExtensionActivity(socket, message) {
1463
- socket.__lastActiveAt =
1464
- typeof message.at === 'number' && Number.isFinite(message.at) ? message.at : Date.now();
1560
+ handleExtensionActivity(socket, _message) {
1561
+ socket.__lastActiveAt = Date.now();
1465
1562
  }
1466
1563
 
1467
1564
  /**
@@ -1603,6 +1700,7 @@ export class BridgeDaemon {
1603
1700
  ok: true,
1604
1701
  id: responseMessage.id,
1605
1702
  source: pending.source || null,
1703
+ ...(pending.mcpEra ? { mcpEra: pending.mcpEra } : {}),
1606
1704
  });
1607
1705
  return;
1608
1706
  }
@@ -1621,6 +1719,10 @@ export class BridgeDaemon {
1621
1719
  * @returns {void}
1622
1720
  */
1623
1721
  handleSocketClose(socket) {
1722
+ if (socket.__registrationTimeoutId) {
1723
+ clearTimeout(socket.__registrationTimeoutId);
1724
+ socket.__registrationTimeoutId = undefined;
1725
+ }
1624
1726
  if (socket.__extensionId) {
1625
1727
  this.artifactStore.deleteByExtension(socket.__extensionId);
1626
1728
  this.domBaselines.clearForSocket(socket);
@@ -1702,6 +1804,7 @@ export class BridgeDaemon {
1702
1804
  ok: false,
1703
1805
  id: requestId,
1704
1806
  source: pending.source || null,
1807
+ ...(pending.mcpEra ? { mcpEra: pending.mcpEra } : {}),
1705
1808
  });
1706
1809
 
1707
1810
  await writeJsonLine(pending.socket, {
@@ -184,7 +184,8 @@ export function getDefaultExtensionId(env = process.env) {
184
184
 
185
185
  /**
186
186
  * Build the allowed_origins list.
187
- * If an extension ID was provided, ensures its origin is present.
187
+ * If an extension ID was provided, it is the complete allowlist so stale
188
+ * origins from an earlier installation do not retain native-host access.
188
189
  * Otherwise falls back to existing origins or a placeholder.
189
190
  *
190
191
  * @param {{allowed_origins?: string[]} | null} existingManifest
@@ -199,14 +200,7 @@ export function getAllowedOrigins(existingManifest, extensionId) {
199
200
 
200
201
  if (extensionId) {
201
202
  const origin = `chrome-extension://${extensionId}/`;
202
- const merged = new Set(existing);
203
- merged.add(origin);
204
- for (const item of [...merged]) {
205
- if (item.includes('__REPLACE_WITH_EXTENSION_ID__')) {
206
- merged.delete(item);
207
- }
208
- }
209
- return [...merged];
203
+ return [origin];
210
204
  }
211
205
 
212
206
  if (existing.length > 0) return existing;
@@ -8,7 +8,7 @@ import {
8
8
  parseJsonLines,
9
9
  sanitizeIncidentalText,
10
10
  } from '../../protocol/src/index.js';
11
- import { readBridgeAuthToken } from './auth-token.js';
11
+ import { readBridgeExtensionAuthToken } from './auth-token.js';
12
12
  import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
13
13
  import { spawnBridgeDaemonProcess } from './daemon-process.js';
14
14
  import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } from './framing.js';
@@ -163,11 +163,11 @@ export async function runNativeHost({
163
163
  socket.once('close', cleanupStdinEndListener);
164
164
  socket.once('end', cleanupStdinEndListener);
165
165
  socket.once('error', cleanupStdinEndListener);
166
- const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
166
+ const authToken = await readBridgeExtensionAuthToken();
167
167
  await writeJsonLine(socket, {
168
168
  type: 'register',
169
169
  role: 'extension',
170
- ...(authToken ? { authToken } : {}),
170
+ authToken,
171
171
  });
172
172
 
173
173
  parseJsonLines(
@@ -45,6 +45,12 @@ export const MAX_BATCH_CALLS = 20;
45
45
 
46
46
  /** Maximum read-only batch calls executing concurrently. */
47
47
  export const MAX_BATCH_CONCURRENCY = 5;
48
+ export const MAX_INTERCEPT_RULES_PER_TAB = 32;
49
+ export const MAX_INTERCEPT_URL_PATTERN_LENGTH = 2_048;
50
+ export const MAX_INTERCEPT_BODY_BYTES = 262_144;
51
+ export const MAX_INTERCEPT_HEADERS = 64;
52
+ export const MAX_INTERCEPT_HEADER_VALUE_BYTES = 8_192;
53
+ export const MAX_INTERCEPT_HEADER_BYTES = 65_536;
48
54
  export const SCREENSHOT_AUTO_INLINE_BYTES = 262_144;
49
55
  export const SCREENSHOT_MAX_INLINE_BYTES = 524_288;
50
56
  export const ARTIFACT_CHUNK_BYTES = 196_608;
@@ -26,6 +26,11 @@ import {
26
26
  MAX_EXTRACT_SETTLE_TIMEOUT_MS,
27
27
  MAX_ARTIFACT_BYTES,
28
28
  MAX_HAR_ENTRIES,
29
+ MAX_INTERCEPT_BODY_BYTES,
30
+ MAX_INTERCEPT_HEADER_BYTES,
31
+ MAX_INTERCEPT_HEADERS,
32
+ MAX_INTERCEPT_HEADER_VALUE_BYTES,
33
+ MAX_INTERCEPT_URL_PATTERN_LENGTH,
29
34
  MAX_SENSITIVE_VALUE_BYTES,
30
35
  } from './defaults.js';
31
36
  import { BridgeError, ERROR_CODES, getErrorRecovery } from './errors.js';
@@ -300,8 +305,16 @@ export function validateBridgeRequest(request) {
300
305
 
301
306
  const candidate = /** @type {Record<string, unknown>} */ (request);
302
307
 
303
- if (typeof candidate.id !== 'string' || !candidate.id.trim()) {
304
- throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Request id must be a non-empty string.');
308
+ if (
309
+ typeof candidate.id !== 'string' ||
310
+ !candidate.id.trim() ||
311
+ candidate.id.length > 128 ||
312
+ hasAsciiControlCharacters(candidate.id)
313
+ ) {
314
+ throw new BridgeError(
315
+ ERROR_CODES.INVALID_REQUEST,
316
+ 'Request id must be a non-empty string of at most 128 characters without controls.'
317
+ );
305
318
  }
306
319
 
307
320
  if (
@@ -372,11 +385,30 @@ export function validateBridgeRequest(request) {
372
385
  typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
373
386
  token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
374
387
  source: meta.source === 'cli' || meta.source === 'mcp' ? meta.source : undefined,
388
+ mcp_era: normalizeMcpEra(meta.source, meta.mcp_era),
375
389
  automatic_retry: normalizeAutomaticRetryMeta(meta.source, meta.automatic_retry),
376
390
  },
377
391
  };
378
392
  }
379
393
 
394
+ /** @param {string} value @returns {boolean} */
395
+ function hasAsciiControlCharacters(value) {
396
+ for (let index = 0; index < value.length; index += 1) {
397
+ const code = value.charCodeAt(index);
398
+ if (code < 32 || code === 127) return true;
399
+ }
400
+ return false;
401
+ }
402
+
403
+ /**
404
+ * @param {unknown} source
405
+ * @param {unknown} value
406
+ * @returns {import('./types.js').McpProtocolEra | undefined}
407
+ */
408
+ function normalizeMcpEra(source, value) {
409
+ return source === 'mcp' && (value === 'legacy' || value === 'modern') ? value : undefined;
410
+ }
411
+
380
412
  /**
381
413
  * @param {unknown} source
382
414
  * @param {unknown} value
@@ -1493,6 +1525,12 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1493
1525
  if (typeof params.urlPattern !== 'string' || !params.urlPattern) {
1494
1526
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'urlPattern is required.');
1495
1527
  }
1528
+ if (params.urlPattern.length > MAX_INTERCEPT_URL_PATTERN_LENGTH) {
1529
+ throw new BridgeError(
1530
+ ERROR_CODES.INVALID_REQUEST,
1531
+ `urlPattern must not exceed ${MAX_INTERCEPT_URL_PATTERN_LENGTH} characters.`
1532
+ );
1533
+ }
1496
1534
 
1497
1535
  const action = params.action ?? DEFAULT_NETWORK_INTERCEPT_ACTION;
1498
1536
  if (action !== 'fulfill' && action !== 'continue' && action !== 'block') {
@@ -1513,6 +1551,15 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1513
1551
  if (params.body !== undefined && typeof params.body !== 'string') {
1514
1552
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'body must be a string.');
1515
1553
  }
1554
+ if (
1555
+ typeof params.body === 'string' &&
1556
+ new TextEncoder().encode(params.body).byteLength > MAX_INTERCEPT_BODY_BYTES
1557
+ ) {
1558
+ throw new BridgeError(
1559
+ ERROR_CODES.INVALID_REQUEST,
1560
+ `body must not exceed ${MAX_INTERCEPT_BODY_BYTES} UTF-8 bytes.`
1561
+ );
1562
+ }
1516
1563
 
1517
1564
  /** @type {Record<string, string> | undefined} */
1518
1565
  let headers;
@@ -1523,8 +1570,16 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1523
1570
  'headers must be an object containing string values.'
1524
1571
  );
1525
1572
  }
1573
+ const entries = Object.entries(params.headers);
1574
+ if (entries.length > MAX_INTERCEPT_HEADERS) {
1575
+ throw new BridgeError(
1576
+ ERROR_CODES.INVALID_REQUEST,
1577
+ `headers must contain at most ${MAX_INTERCEPT_HEADERS} entries.`
1578
+ );
1579
+ }
1526
1580
  headers = {};
1527
- for (const [name, value] of Object.entries(params.headers)) {
1581
+ let headerBytes = 0;
1582
+ for (const [name, value] of entries) {
1528
1583
  if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
1529
1584
  throw new BridgeError(
1530
1585
  ERROR_CODES.INVALID_REQUEST,
@@ -1537,6 +1592,20 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1537
1592
  `Header ${name} must have a string value without newlines.`
1538
1593
  );
1539
1594
  }
1595
+ const valueBytes = new TextEncoder().encode(value).byteLength;
1596
+ if (valueBytes > MAX_INTERCEPT_HEADER_VALUE_BYTES) {
1597
+ throw new BridgeError(
1598
+ ERROR_CODES.INVALID_REQUEST,
1599
+ `Header ${name} must not exceed ${MAX_INTERCEPT_HEADER_VALUE_BYTES} UTF-8 bytes.`
1600
+ );
1601
+ }
1602
+ headerBytes += new TextEncoder().encode(name).byteLength + valueBytes;
1603
+ if (headerBytes > MAX_INTERCEPT_HEADER_BYTES) {
1604
+ throw new BridgeError(
1605
+ ERROR_CODES.INVALID_REQUEST,
1606
+ `headers must not exceed ${MAX_INTERCEPT_HEADER_BYTES} UTF-8 bytes in total.`
1607
+ );
1608
+ }
1540
1609
  headers[name] = value;
1541
1610
  }
1542
1611
  }
@@ -1,4 +1,5 @@
1
1
  export type BridgeRequestSource = 'cli' | 'mcp';
2
+ export type McpProtocolEra = 'legacy' | 'modern';
2
3
 
3
4
  export type AccessIntent = 'inspect' | 'interact' | 'capture' | 'navigate' | 'debugger' | 'general';
4
5
 
@@ -153,6 +154,7 @@ export interface BridgeMeta {
153
154
  image_approx_tokens?: number;
154
155
  image_bytes?: number;
155
156
  source?: BridgeRequestSource;
157
+ mcp_era?: McpProtocolEra;
156
158
  response_bytes?: number;
157
159
  approx_tokens?: number;
158
160
  cost_class?: CostClass;
@@ -1051,6 +1053,8 @@ export interface NetworkResult {
1051
1053
  dropped: number;
1052
1054
  abandoned: number;
1053
1055
  source: NetworkSource;
1056
+ provenance: 'page_main_world' | 'chrome_devtools_protocol';
1057
+ integrity: 'untrusted' | 'browser_observed';
1054
1058
  capture: NetworkCaptureAction | null;
1055
1059
  armed: boolean;
1056
1060
  armedDuringCapture: boolean;
@@ -15,7 +15,7 @@ Skill name: `browser-bridge` (also known as `bbx`). In GitHub Copilot, invoke as
15
15
  When the runtime supports subagents, delegate bridge inspection to a smaller, lower-cost worker and return only concise findings to the parent.
16
16
  For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `page.extract_content`, `styles.get_computed`, and `bbx batch` for CLI or `browser_batch` for MCP) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient. `browser_call` accepts one protocol method at a time; `batch` is not a valid `browser_call` method.
17
17
  When an unfamiliar method is needed, run `bbx protocol describe <method|group>` before guessing parameters. In MCP mode, call `browser_call` with method `protocol.describe` and params `{ "method": "dom.query" }` or `{ "group": "inspect" }`.
18
- MCP exposes common tools first. Load one specialized typed tool only when needed by calling `browser_toolset` with its exact name, for example `{ "tool": "browser_dom" }`; `browser_call` remains the universal fallback.
18
+ MCP exposes a compact tool surface. If `browser_toolset` is available on a legacy connection, load one specialized typed tool only when needed, for example `{ "tool": "browser_dom" }`. Modern stateless connections use a fixed surface with `browser_skill` instead. `browser_call` remains the universal path in both eras.
19
19
 
20
20
  ## CLI
21
21