@browserbridge/bbx 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -2
  3. package/packages/agent-client/src/cli.js +45 -16
  4. package/packages/agent-client/src/client.js +74 -20
  5. package/packages/agent-client/src/command-registry.js +2 -3
  6. package/packages/agent-client/src/mcp-config.js +30 -27
  7. package/packages/agent-client/src/runtime.js +2 -10
  8. package/packages/agent-client/src/types.ts +10 -1
  9. package/packages/mcp-server/src/guidance.js +241 -0
  10. package/packages/mcp-server/src/handlers-capture.js +74 -11
  11. package/packages/mcp-server/src/handlers-dom.js +48 -0
  12. package/packages/mcp-server/src/handlers-navigation.js +22 -2
  13. package/packages/mcp-server/src/handlers-page.js +10 -9
  14. package/packages/mcp-server/src/handlers-utils.js +47 -1
  15. package/packages/mcp-server/src/server.js +111 -29
  16. package/packages/native-host/src/auth-token.js +92 -0
  17. package/packages/native-host/src/daemon-process.js +26 -4
  18. package/packages/native-host/src/daemon.js +174 -28
  19. package/packages/native-host/src/framing.js +7 -2
  20. package/packages/native-host/src/native-host.js +18 -2
  21. package/packages/protocol/src/defaults.js +3 -0
  22. package/packages/protocol/src/json-lines.js +29 -1
  23. package/packages/protocol/src/protocol.js +6 -1
  24. package/packages/protocol/src/types.ts +2 -0
  25. package/skills/browser-bridge/SKILL.md +21 -5
  26. package/skills/browser-bridge/agents/openai.yaml +1 -1
  27. package/skills/browser-bridge/references/interaction.md +6 -6
  28. package/skills/browser-bridge/references/protocol.md +57 -54
  29. package/skills/browser-bridge/references/ui-workflows.md +1 -1
@@ -36,6 +36,11 @@ import {
36
36
  getBridgeTransport,
37
37
  getSocketPath,
38
38
  } from './config.js';
39
+ import {
40
+ ensureBridgeAuthToken,
41
+ normalizeBridgeAuthToken,
42
+ readBridgeAuthToken,
43
+ } from './auth-token.js';
39
44
  import { normalizeDaemonLogger } from './daemon-logger.js';
40
45
  import { writeJsonLine } from './framing.js';
41
46
 
@@ -47,8 +52,8 @@ const DAEMON_VERSION = loadDaemonVersion();
47
52
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
48
53
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
49
54
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
50
- /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
51
- /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
55
+ /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number, __authenticated?: boolean }} ClientSocket */
56
+ /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
52
57
  /**
53
58
  * @typedef {{
54
59
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -134,6 +139,7 @@ export function isWindowsNamedPipePath(socketPath) {
134
139
  * browserName?: string,
135
140
  * profileLabel?: string,
136
141
  * accessEnabled?: boolean,
142
+ * authToken?: string,
137
143
  * at?: number
138
144
  * }} DaemonMessage
139
145
  */
@@ -146,7 +152,8 @@ export class BridgeDaemon {
146
152
  * listenOptions?: import('node:net').ListenOptions | null,
147
153
  * setupStatusLoader?: () => Promise<SetupStatus>,
148
154
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
149
- * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>
155
+ * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
156
+ * authToken?: string | null
150
157
  * }} [options={}]
151
158
  */
152
159
  constructor({
@@ -156,6 +163,7 @@ export class BridgeDaemon {
156
163
  setupStatusLoader = collectSetupStatus,
157
164
  setupInstaller = installSetupTarget,
158
165
  logger = undefined,
166
+ authToken = undefined,
159
167
  } = {}) {
160
168
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
161
169
  this.socketPath =
@@ -196,6 +204,15 @@ export class BridgeDaemon {
196
204
  this.totalResponseTimeMs = 0;
197
205
  /** @type {Map<string, number>} */
198
206
  this.requestStartTimes = new Map();
207
+ /** @type {string | null | undefined} */
208
+ this.authToken = authToken;
209
+ }
210
+
211
+ /**
212
+ * @returns {boolean}
213
+ */
214
+ isAuthRequired() {
215
+ return Boolean(this.authToken);
199
216
  }
200
217
 
201
218
  /**
@@ -260,6 +277,7 @@ export class BridgeDaemon {
260
277
  if (!pending) {
261
278
  return undefined;
262
279
  }
280
+ clearTimeout(pending.timeoutId);
263
281
  this.pendingRequests.delete(requestId);
264
282
  this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
265
283
  for (const targetSocket of pending.targets) {
@@ -306,7 +324,20 @@ export class BridgeDaemon {
306
324
  * @returns {void}
307
325
  */
308
326
  registerSocket(socket, message) {
327
+ if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
328
+ this.logger.error('socket registration rejected', { role: message.role ?? null });
329
+ void writeJsonLine(socket, {
330
+ type: 'registration_failed',
331
+ error: {
332
+ code: ERROR_CODES.ACCESS_DENIED,
333
+ message: 'Bridge daemon authentication failed.',
334
+ },
335
+ }).finally(() => socket.destroy());
336
+ return;
337
+ }
338
+
309
339
  if (message.role === 'extension') {
340
+ socket.__authenticated = true;
310
341
  const extensionId = randomUUID();
311
342
  socket.__extensionId = extensionId;
312
343
  socket.__browserName =
@@ -326,6 +357,7 @@ export class BridgeDaemon {
326
357
  }
327
358
 
328
359
  if (message.role === 'agent') {
360
+ socket.__authenticated = true;
329
361
  const clientId = message.clientId || randomUUID();
330
362
  this.agentSockets.set(clientId, socket);
331
363
  socket.__clientId = clientId;
@@ -342,6 +374,10 @@ export class BridgeDaemon {
342
374
  * @returns {Promise<BridgeDaemon>}
343
375
  */
344
376
  async start() {
377
+ if (this.authToken === undefined) {
378
+ this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
379
+ }
380
+
345
381
  if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
346
382
  const socketDir = path.dirname(this.socketPath);
347
383
  await fs.promises.mkdir(socketDir, { recursive: true });
@@ -372,14 +408,22 @@ export class BridgeDaemon {
372
408
  typedSocket.on('error', (err) => {
373
409
  this.logger.error('socket error', { message: err.message });
374
410
  });
375
- parseJsonLines(typedSocket, (raw) => {
376
- const message = /** @type {DaemonMessage} */ (raw);
377
- void this.handleClientMessage(typedSocket, message).catch((err) => {
378
- this.logger.error('handler error', {
379
- message: err instanceof Error ? err.message : String(err),
411
+ parseJsonLines(
412
+ typedSocket,
413
+ (raw) => {
414
+ const message = /** @type {DaemonMessage} */ (raw);
415
+ void this.handleClientMessage(typedSocket, message).catch((err) => {
416
+ this.logger.error('handler error', {
417
+ message: err instanceof Error ? err.message : String(err),
418
+ });
380
419
  });
381
- });
382
- });
420
+ },
421
+ {
422
+ onProtocolError: (error) => {
423
+ this.logger.error('socket protocol error', { message: error.message });
424
+ },
425
+ }
426
+ );
383
427
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
384
428
  });
385
429
 
@@ -477,6 +521,10 @@ export class BridgeDaemon {
477
521
  return this.registerSocket(socket, message);
478
522
  }
479
523
 
524
+ if (this.isAuthRequired() && !socket.__authenticated) {
525
+ return this.rejectUnauthenticatedMessage(socket, message);
526
+ }
527
+
480
528
  if (message?.type === 'log') {
481
529
  this.pushLog(message.entry ?? {});
482
530
  return;
@@ -515,13 +563,73 @@ export class BridgeDaemon {
515
563
  });
516
564
  }
517
565
 
566
+ /**
567
+ * @param {ClientSocket} socket
568
+ * @param {DaemonMessage} message
569
+ * @returns {Promise<void>}
570
+ */
571
+ async rejectUnauthenticatedMessage(socket, message) {
572
+ if (message?.type === 'agent.request') {
573
+ const candidate =
574
+ message.request && typeof message.request === 'object'
575
+ ? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
576
+ : {};
577
+ const response = createFailure(
578
+ typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
579
+ ERROR_CODES.ACCESS_DENIED,
580
+ 'Register with the daemon auth token before sending bridge requests.',
581
+ null,
582
+ typeof candidate.method === 'string' ? { method: candidate.method } : {}
583
+ );
584
+ await writeJsonLine(socket, { type: 'agent.response', response });
585
+ return;
586
+ }
587
+
588
+ await writeJsonLine(socket, {
589
+ type: 'error',
590
+ error: {
591
+ code: ERROR_CODES.ACCESS_DENIED,
592
+ message: 'Register with the daemon auth token before sending bridge messages.',
593
+ },
594
+ });
595
+ }
596
+
518
597
  /**
519
598
  * @param {ClientSocket} socket
520
599
  * @param {DaemonMessage} message
521
600
  * @returns {Promise<void>}
522
601
  */
523
602
  async handleAgentRequest(socket, message) {
524
- const request = validateBridgeRequest(message.request);
603
+ /** @type {BridgeRequest} */
604
+ let request;
605
+ try {
606
+ request = validateBridgeRequest(message.request);
607
+ } catch (error) {
608
+ const candidate =
609
+ message.request && typeof message.request === 'object'
610
+ ? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
611
+ : {};
612
+ const response = createFailure(
613
+ typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'invalid_request',
614
+ ERROR_CODES.INVALID_REQUEST,
615
+ error instanceof Error ? error.message : String(error),
616
+ null,
617
+ typeof candidate.method === 'string' ? { method: candidate.method } : {}
618
+ );
619
+ await writeJsonLine(socket, { type: 'agent.response', response });
620
+ return;
621
+ }
622
+
623
+ if (this.pendingRequests.has(request.id)) {
624
+ const response = createFailure(
625
+ request.id,
626
+ ERROR_CODES.INVALID_REQUEST,
627
+ `Request id ${JSON.stringify(request.id)} is already in flight.`
628
+ );
629
+ await writeJsonLine(socket, { type: 'agent.response', response });
630
+ return;
631
+ }
632
+
525
633
  if (request.method === 'health.ping') {
526
634
  if (this.extensionSockets.size === 0) {
527
635
  const response = createSuccess(request.id, {
@@ -654,6 +762,7 @@ export class BridgeDaemon {
654
762
  this.trackPendingRequest(request.id, {
655
763
  socket,
656
764
  method: request.method,
765
+ protocolVersion: request.meta?.protocol_version,
657
766
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
658
767
  targets: new Set(targets),
659
768
  timeoutId: setTimeout(() => {
@@ -669,6 +778,12 @@ export class BridgeDaemon {
669
778
  void writeJsonLine(pending.socket, {
670
779
  type: 'agent.response',
671
780
  response,
781
+ }).catch((error) => {
782
+ this.logger.error('timeout response write failed', {
783
+ requestId: request.id,
784
+ method: pending.method,
785
+ message: error instanceof Error ? error.message : String(error),
786
+ });
672
787
  });
673
788
  }, this.pendingTimeoutMs),
674
789
  });
@@ -792,7 +907,6 @@ export class BridgeDaemon {
792
907
  this.removePendingTarget(responseMessage.id, pending, socket);
793
908
 
794
909
  if (responseMessage.ok) {
795
- clearTimeout(pending.timeoutId);
796
910
  this.clearPendingRequest(responseMessage.id, pending);
797
911
  this.recordRequestCompletion(responseMessage.id, true);
798
912
  this.pushLog({
@@ -812,6 +926,7 @@ export class BridgeDaemon {
812
926
  socketPath: this.socketPath,
813
927
  transport: formatBridgeTransport(this.transport),
814
928
  connectedExtensions: this.getConnectedExtensionsSnapshot(),
929
+ ...getVersionNegotiationPayload(pending.protocolVersion),
815
930
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
816
931
  daemonVersion: DAEMON_VERSION,
817
932
  daemon_supported_versions: SUPPORTED_VERSIONS,
@@ -849,7 +964,9 @@ export class BridgeDaemon {
849
964
 
850
965
  if (socket.__clientId) {
851
966
  this.logger.info('agent disconnected', { clientId: socket.__clientId });
852
- this.agentSockets.delete(socket.__clientId);
967
+ if (this.agentSockets.get(socket.__clientId) === socket) {
968
+ this.agentSockets.delete(socket.__clientId);
969
+ }
853
970
  }
854
971
 
855
972
  const ownedRequestIds = this.pendingRequestsByOwnerSocket.get(socket);
@@ -859,7 +976,6 @@ export class BridgeDaemon {
859
976
  if (!pending) {
860
977
  continue;
861
978
  }
862
- clearTimeout(pending.timeoutId);
863
979
  this.clearPendingRequest(id, pending);
864
980
  this.recordRequestCompletion(id, false);
865
981
  }
@@ -873,7 +989,13 @@ export class BridgeDaemon {
873
989
  continue;
874
990
  }
875
991
  this.removePendingTarget(id, pending, socket);
876
- void this.finishPendingRequestIfExhausted(id, pending);
992
+ void this.finishPendingRequestIfExhausted(id, pending).catch((error) => {
993
+ this.logger.error('pending exhaustion response failed', {
994
+ requestId: id,
995
+ method: pending.method,
996
+ message: error instanceof Error ? error.message : String(error),
997
+ });
998
+ });
877
999
  }
878
1000
  }
879
1001
  }
@@ -891,7 +1013,6 @@ export class BridgeDaemon {
891
1013
  return;
892
1014
  }
893
1015
 
894
- clearTimeout(pending.timeoutId);
895
1016
  this.clearPendingRequest(requestId, pending);
896
1017
  this.recordRequestCompletion(requestId, false);
897
1018
 
@@ -958,10 +1079,22 @@ export class BridgeDaemon {
958
1079
  export async function pingExistingDaemon(transport) {
959
1080
  const resolvedTransport =
960
1081
  typeof transport === 'string' ? createSocketBridgeTransport(transport) : transport;
1082
+ const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
961
1083
  return new Promise((resolve) => {
962
- const timeout = setTimeout(() => {
1084
+ let settled = false;
1085
+ /** @param {boolean} value */
1086
+ function finish(value) {
1087
+ if (settled) {
1088
+ return;
1089
+ }
1090
+ settled = true;
1091
+ clearTimeout(timeout);
963
1092
  socket.destroy();
964
- resolve(false);
1093
+ resolve(value);
1094
+ }
1095
+
1096
+ const timeout = setTimeout(() => {
1097
+ finish(false);
965
1098
  }, DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
966
1099
 
967
1100
  const socket =
@@ -969,27 +1102,37 @@ export async function pingExistingDaemon(transport) {
969
1102
  ? net.createConnection({ host: resolvedTransport.host, port: resolvedTransport.port })
970
1103
  : net.createConnection(resolvedTransport.socketPath);
971
1104
  socket.once('error', () => {
972
- clearTimeout(timeout);
973
- resolve(false);
1105
+ finish(false);
974
1106
  });
975
1107
 
976
1108
  let buf = '';
977
1109
  socket.setEncoding('utf8');
978
1110
  socket.on('data', (chunk) => {
979
1111
  buf += chunk;
980
- if (buf.includes('\n')) {
981
- clearTimeout(timeout);
982
- socket.destroy();
1112
+ while (buf.includes('\n')) {
1113
+ const index = buf.indexOf('\n');
1114
+ const line = buf.slice(0, index).trim();
1115
+ buf = buf.slice(index + 1);
1116
+ if (!line) {
1117
+ continue;
1118
+ }
983
1119
  try {
984
- const msg = JSON.parse(buf.slice(0, buf.indexOf('\n')).trim());
985
- resolve(msg?.response?.result?.daemon === 'ok');
1120
+ const msg = JSON.parse(line);
1121
+ if (msg?.type === 'agent.response') {
1122
+ finish(msg?.response?.result?.daemon === 'ok');
1123
+ }
986
1124
  } catch {
987
- resolve(false);
1125
+ finish(false);
988
1126
  }
989
1127
  }
990
1128
  });
991
1129
 
992
1130
  socket.once('connect', () => {
1131
+ if (authToken) {
1132
+ socket.write(
1133
+ `${JSON.stringify({ type: 'register', role: 'agent', clientId: 'ping_probe', authToken })}\n`
1134
+ );
1135
+ }
993
1136
  socket.write(
994
1137
  `${JSON.stringify({
995
1138
  type: 'agent.request',
@@ -1011,7 +1154,10 @@ export async function pingExistingDaemon(transport) {
1011
1154
  * @returns {SetupInstallParams & { action: 'install' | 'uninstall', kind: 'mcp' | 'skill', target: string }}
1012
1155
  */
1013
1156
  export function normalizeSetupInstallParams(params) {
1014
- const action = params.action === 'uninstall' ? 'uninstall' : 'install';
1157
+ const action = params.action == null ? 'install' : params.action;
1158
+ if (action !== 'install' && action !== 'uninstall') {
1159
+ throw new Error('setup.install action must be "install" or "uninstall".');
1160
+ }
1015
1161
  const kind = params.kind === 'mcp' || params.kind === 'skill' ? params.kind : null;
1016
1162
  const target = typeof params.target === 'string' ? params.target.trim().toLowerCase() : '';
1017
1163
  if (!kind) {
@@ -1020,7 +1166,7 @@ export function normalizeSetupInstallParams(params) {
1020
1166
  if (!target) {
1021
1167
  throw new Error('setup.install requires a target.');
1022
1168
  }
1023
- return { action, kind, target };
1169
+ return { action: /** @type {'install' | 'uninstall'} */ (action), kind, target };
1024
1170
  }
1025
1171
 
1026
1172
  /**
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { once } from 'node:events';
4
4
 
5
- import { MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
5
+ import { MAX_JSON_LINE_BYTES, MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
6
6
 
7
7
  /**
8
8
  * @param {NodeJS.WritableStream} stream
@@ -193,7 +193,12 @@ export function createNativeMessageReader(stream, onMessage, onProtocolError) {
193
193
  * @returns {Promise<void>}
194
194
  */
195
195
  export async function writeJsonLine(socket, message) {
196
- if (!socket.write(`${JSON.stringify(message)}\n`)) {
196
+ const line = `${JSON.stringify(message)}\n`;
197
+ const byteLength = Buffer.byteLength(line.slice(0, -1), 'utf8');
198
+ if (byteLength > MAX_JSON_LINE_BYTES) {
199
+ throw new Error(`JSON line exceeds ${MAX_JSON_LINE_BYTES} bytes: ${byteLength}`);
200
+ }
201
+ if (!socket.write(line)) {
197
202
  await once(socket, 'drain');
198
203
  }
199
204
  }
@@ -2,7 +2,8 @@
2
2
 
3
3
  import net from 'node:net';
4
4
 
5
- import { createFailure, ERROR_CODES } from '../../protocol/src/index.js';
5
+ import { createFailure, ERROR_CODES, MAX_JSON_LINE_BYTES } from '../../protocol/src/index.js';
6
+ import { readBridgeAuthToken } from './auth-token.js';
6
7
  import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
7
8
  import { spawnBridgeDaemonProcess } from './daemon-process.js';
8
9
  import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } from './framing.js';
@@ -146,11 +147,21 @@ export async function runNativeHost({
146
147
  socket.once('close', cleanupStdinEndListener);
147
148
  socket.once('end', cleanupStdinEndListener);
148
149
  socket.once('error', cleanupStdinEndListener);
149
- await writeJsonLine(socket, { type: 'register', role: 'extension' });
150
+ const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
151
+ await writeJsonLine(socket, {
152
+ type: 'register',
153
+ role: 'extension',
154
+ ...(authToken ? { authToken } : {}),
155
+ });
150
156
 
151
157
  let lineBuffer = '';
152
158
  socket.on('data', (chunk) => {
153
159
  lineBuffer += chunk;
160
+ if (!lineBuffer.includes('\n') && Buffer.byteLength(lineBuffer, 'utf8') > MAX_JSON_LINE_BYTES) {
161
+ console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
162
+ socket.destroy();
163
+ return;
164
+ }
154
165
  while (lineBuffer.includes('\n')) {
155
166
  const index = lineBuffer.indexOf('\n');
156
167
  const line = lineBuffer.slice(0, index).trim();
@@ -158,6 +169,11 @@ export async function runNativeHost({
158
169
  if (!line) {
159
170
  continue;
160
171
  }
172
+ if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
173
+ console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
174
+ socket.destroy();
175
+ return;
176
+ }
161
177
  let message;
162
178
  try {
163
179
  message = JSON.parse(line);
@@ -24,6 +24,9 @@ export const DEFAULT_DEVICE_SCALE_FACTOR = 0;
24
24
  /** Maximum size of a Chrome native messaging message in bytes. */
25
25
  export const MAX_NATIVE_MESSAGE_BYTES = 1_048_576;
26
26
 
27
+ /** Maximum size of one newline-delimited daemon socket message in bytes. */
28
+ export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
29
+
27
30
  /** Default timeout for a bridge request awaiting an extension response (ms). */
28
31
  export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
29
32
 
@@ -1,18 +1,42 @@
1
1
  // @ts-check
2
2
 
3
+ import { MAX_JSON_LINE_BYTES } from './defaults.js';
4
+
3
5
  /**
4
6
  * Install a newline-delimited JSON parser on a socket's `data` event.
5
7
  *
6
8
  * @param {import('node:net').Socket} socket
7
9
  * @param {(message: unknown) => void} onMessage
10
+ * @param {{ maxLineBytes?: number, onProtocolError?: (error: Error) => void }} [options]
8
11
  * @returns {void}
9
12
  */
10
- export function parseJsonLines(socket, onMessage) {
13
+ export function parseJsonLines(socket, onMessage, options = {}) {
11
14
  let buffer = '';
15
+ const maxLineBytes =
16
+ typeof options.maxLineBytes === 'number' && Number.isFinite(options.maxLineBytes)
17
+ ? Math.max(1, Math.floor(options.maxLineBytes))
18
+ : MAX_JSON_LINE_BYTES;
12
19
  socket.setEncoding('utf8');
20
+
21
+ /**
22
+ * @param {Error} error
23
+ * @returns {void}
24
+ */
25
+ function fail(error) {
26
+ options.onProtocolError?.(error);
27
+ const destroy = /** @type {{ destroy?: (() => void) | undefined }} */ (socket).destroy;
28
+ if (typeof destroy === 'function') {
29
+ destroy.call(socket);
30
+ }
31
+ }
32
+
13
33
  /** @param {string} chunk */
14
34
  socket.on('data', (chunk) => {
15
35
  buffer += chunk;
36
+ if (!buffer.includes('\n') && Buffer.byteLength(buffer, 'utf8') > maxLineBytes) {
37
+ fail(new Error(`JSON line exceeds ${maxLineBytes} bytes.`));
38
+ return;
39
+ }
16
40
  while (buffer.includes('\n')) {
17
41
  const index = buffer.indexOf('\n');
18
42
  const line = buffer.slice(0, index).trim();
@@ -20,6 +44,10 @@ export function parseJsonLines(socket, onMessage) {
20
44
  if (!line) {
21
45
  continue;
22
46
  }
47
+ if (Buffer.byteLength(line, 'utf8') > maxLineBytes) {
48
+ fail(new Error(`JSON line exceeds ${maxLineBytes} bytes.`));
49
+ return;
50
+ }
23
51
  try {
24
52
  onMessage(JSON.parse(line));
25
53
  } catch {
@@ -97,7 +97,9 @@ export const SUPPORTED_VERSIONS = Object.freeze(['1.0']);
97
97
  * @returns {number}
98
98
  */
99
99
  function clampInt(value, min, max, fallback) {
100
- return Math.min(Math.max(Number(value) || fallback, min), max);
100
+ const numeric = Number(value);
101
+ const integer = Number.isFinite(numeric) && numeric !== 0 ? Math.trunc(numeric) : fallback;
102
+ return Math.min(Math.max(integer, min), max);
101
103
  }
102
104
 
103
105
  /** @type {ReadonlyArray<BridgeMethod>} */
@@ -672,6 +674,9 @@ export function normalizeHoverParams(params = {}) {
672
674
  : undefined
673
675
  ),
674
676
  duration: clampInt(params.duration, 0, 5_000, 0),
677
+ modifiers: Array.isArray(params.modifiers)
678
+ ? params.modifiers.filter((modifier) => typeof modifier === 'string' && modifier.trim())
679
+ : [],
675
680
  };
676
681
  }
677
682
 
@@ -460,11 +460,13 @@ export interface NormalizedGetHtmlParams extends BridgeParams {
460
460
  export interface HoverParams {
461
461
  target?: InputTarget;
462
462
  duration?: number;
463
+ modifiers?: string[];
463
464
  }
464
465
 
465
466
  export interface NormalizedHoverParams extends BridgeParams {
466
467
  target: InputTarget;
467
468
  duration: number;
469
+ modifiers: string[];
468
470
  }
469
471
 
470
472
  export interface DragParams {
@@ -10,14 +10,16 @@ Token-efficient Chrome tab inspection, interaction, and CSS/DOM patching through
10
10
  This CLI skill is for agents that can run shell commands and where direct `bbx` control fits better than MCP tools: manual debugging, terminal reproduction, install/doctor flows, raw protocol access, or environments without MCP.
11
11
 
12
12
  Skill name: `browser-bridge` (also known as `bbx`). In GitHub Copilot, invoke as `/browser-bridge`. `bbx` is the CLI command used throughout this skill.
13
- Use a subagent for bridge calls; return only concise findings to the parent.
14
- For open-ended investigation, prefer a smaller, lower-cost subagent first. Start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, `bbx batch`) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient.
13
+ When the runtime supports subagents, delegate bridge inspection to a smaller, lower-cost worker and return only concise findings to the parent.
14
+ For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, `bbx batch`) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient.
15
15
 
16
16
  ## CLI
17
17
 
18
18
  ```bash
19
19
  bbx status # daemon + extension health
20
20
  bbx doctor # install/access readiness
21
+ bbx access-request # ask user to enable access for the focused window
22
+ bbx restart # start/restart the local daemon non-interactively
21
23
  bbx call <method> '{...}' # any RPC method (raw output)
22
24
  bbx <method> '{...}' # direct alias for an exact bridge method such as page.get_state
23
25
  bbx call --tab 123 <method> '{...}' # explicit tab override
@@ -59,6 +61,7 @@ bbx navigate <url> # navigate to URL
59
61
  bbx reload # reload current page
60
62
  bbx back # navigate back
61
63
  bbx forward # navigate forward
64
+ bbx scroll <top> [left] # scroll viewport
62
65
  bbx resize <width> <height> # resize viewport
63
66
  ```
64
67
 
@@ -77,7 +80,7 @@ bbx patch-text <ref> <text...> # apply text patch
77
80
  bbx patches # list active patches
78
81
  bbx rollback <patchId> # rollback a patch
79
82
  bbx screenshot <ref> [outPath] # capture partial element screenshot
80
- bbx call screenshot.capture_full_page '{}' # full-page screenshot when document context matters
83
+ bbx call screenshot.capture_full_page '{}' # raw base64; avoid unless document context matters
81
84
  ```
82
85
 
83
86
  ## Access Flow
@@ -114,8 +117,8 @@ After access is enabled:
114
117
  | `EXTENSION_DISCONNECTED` | After 3s | Check Chrome is running; `bbx status` to verify, then retry |
115
118
  | `NATIVE_HOST_UNAVAILABLE` | No | Run `bbx doctor` to diagnose the installation |
116
119
  | `INTERNAL_ERROR` | Once | Retry once; if persistent, check `page.get_console` for details |
117
- | `DAEMON_OFFLINE` | No | Daemon not running - start with `bbx-daemon` |
118
- | `CONNECTION_LOST` | Yes | Socket dropped mid-request - retry; if persistent, restart daemon |
120
+ | `DAEMON_OFFLINE` | No | Daemon not running - start with `bbx restart` |
121
+ | `CONNECTION_LOST` | Yes | Socket dropped mid-request - retry; if persistent, run `bbx restart` |
119
122
  | `BRIDGE_TIMEOUT` | Once | Extension took too long - retry once with simpler call |
120
123
 
121
124
  Error responses now include a machine-readable `error.recovery` field with `retry`, `retryAfterMs`, `alternativeMethod`, and `hint`.
@@ -160,6 +163,12 @@ For a natural-language inspection task:
160
163
  4. Escalate to `screenshot.capture_element`, `screenshot.capture_region`, or other debugger-backed methods only when structured reads are ambiguous or visual confirmation is required.
161
164
  5. Return concise findings and evidence, not raw dumps.
162
165
 
166
+ CLI-first starter:
167
+
168
+ ```bash
169
+ bbx batch '[{"method":"page.get_state"},{"method":"dom.query","params":{"selector":"main","maxNodes":10,"maxDepth":3,"textBudget":400,"attributeAllowlist":["id","class","data-testid"]}},{"method":"page.get_text","params":{"textBudget":2000}}]'
170
+ ```
171
+
163
172
  ## Common Workflows
164
173
 
165
174
  ### Debug a CSS layout issue
@@ -269,6 +278,13 @@ Return: verdict, tab id + origin, minimal evidence set. No raw HTML or base64 im
269
278
 
270
279
  Every CLI shortcut command produces consistent `{ok, summary, evidence}` JSON. Use `bbx call <method>` for raw protocol output when needed.
271
280
 
281
+ ## CLI Raw Params Gotchas
282
+
283
+ - Use `selector`, not `scope`, to narrow `dom.find_by_text` and `dom.find_by_role`.
284
+ - Wrap interaction targets as `target: { elementRef }` or `target: { selector }`; `viewport.scroll` also uses the `target` wrapper for element scrolling.
285
+ - `input.drag` uses `source`, `destination`, and optional destination offsets `offsetX` / `offsetY`.
286
+ - Raw `screenshot.capture_region` and `screenshot.capture_full_page` return base64 JSON; prefer `bbx screenshot <ref> [outPath]` when one element is enough.
287
+
272
288
  ## Response Shapes
273
289
 
274
290
  The summarizer auto-detects response types and produces concise summaries:
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: 'Browser Bridge'
3
3
  short_description: 'Token-efficient Chrome tab inspection, interaction, and patching via local bridge'
4
- default_prompt: 'Use the browser-bridge skill for Chrome tab inspection, interaction, and patching. Start with `bbx status` to confirm connectivity. If ACCESS_DENIED, ask the user to click Enable in the extension popup/side panel, then retry. Default routing follows the active tab; use `--tab` only for a different tab. Prefer structured reads (`dom.query`, `styles.get_computed`) over screenshots. Batch independent reads with `bbx batch`. Use `bbx skill` for runtime presets.'
4
+ default_prompt: 'Use the browser-bridge skill for Chrome tab inspection, interaction, and patching. Start with `bbx status` to confirm connectivity; if the daemon is offline, run `bbx restart`. If ACCESS_DENIED, ask the user to click Enable in the extension popup/side panel, then retry. Default routing follows the active tab; use `--tab` only for a different tab. Prefer structured reads (`dom.query`, `styles.get_computed`) over screenshots. Batch independent reads with `bbx batch`. Use `bbx skill` for runtime presets.'