@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
package/README.md CHANGED
@@ -110,7 +110,9 @@ Browser Bridge is optimized for the opposite starting point: **inspect the state
110
110
  3. Run `bbx install`, or target a specific browser with `bbx install --browser edge`, `bbx install --browser brave`, `bbx install --browser chromium`, or `bbx install --browser arc`
111
111
  4. In the extension side panel, install MCP or CLI (skill) for your agent of choice
112
112
  5. Enable Browser Bridge for the browser window you want to inspect/control with the AI agent
113
- 6. Ask your agent to use Browser Bridge via MCP (`BB MCP` or `Browser Bridge MCP`), or invoke the `browser-bridge` / `$bbx` skill in CLI mode
113
+ 6. Ask your agent to use Browser Bridge via MCP (`BB MCP` or `Browser Bridge MCP`), or invoke the installed Browser Bridge skill in CLI mode (`/browser-bridge`, `browser-bridge`, or the client-specific skill trigger)
114
+
115
+ MCP mode is self-contained: the server exposes tools, startup instructions, and prompt templates, so a separate CLI skill is not required for MCP guidance.
114
116
 
115
117
  ## How it works
116
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -61,7 +61,7 @@
61
61
  "postinstall": "node packages/native-host/bin/postinstall.js",
62
62
  "package:extension": "node scripts/package-extension.mjs",
63
63
  "check:extension-zip": "node scripts/check-extension-zip.mjs",
64
- "release:check": "npm run lint && npm run typecheck && npm test && npm run coverage:check && npm run package:extension && npm pack --dry-run",
64
+ "release:check": "npm run lint && npm run typecheck && npm test && npm run coverage:check && npm run package:extension && npm run check:extension-zip && npm pack --dry-run",
65
65
  "prepublishOnly": "npm run lint && npm run typecheck && npm test && npm run coverage:check",
66
66
  "status": "node packages/agent-client/src/cli.js status",
67
67
  "daemon": "node packages/native-host/bin/bridge-daemon.js",
@@ -128,15 +128,21 @@ if (command === 'install-skill') {
128
128
  const positional = rest.filter((a) => !a.startsWith('--'));
129
129
 
130
130
  if (positional.length === 0) {
131
- // Parse scope flags without going through parseInstallAgentArgs.
132
- let isGlobal = true;
133
- if (rest.includes('--local')) isGlobal = false;
134
- if (rest.includes('--global')) isGlobal = true;
131
+ let scopeOptions;
132
+ try {
133
+ scopeOptions = parseInstallAgentArgs(rest);
134
+ } catch (error) {
135
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
136
+ process.exit(1);
137
+ }
138
+
139
+ const isGlobal = scopeOptions.global !== false;
140
+ const projectPath = isGlobal ? os.homedir() : scopeOptions.projectPath;
135
141
 
136
142
  const setupStatus = await collectSetupStatus({
137
143
  global: isGlobal,
138
144
  cwd: process.cwd(),
139
- projectPath: isGlobal ? os.homedir() : process.cwd(),
145
+ projectPath,
140
146
  ...getSetupStatusTestOverrides(),
141
147
  });
142
148
  /** @type {import('./types.js').SupportedTarget[]} */
@@ -176,7 +182,6 @@ if (command === 'install-skill') {
176
182
  targets = /** @type {import('./types.js').SupportedTarget[]} */ (selected);
177
183
  }
178
184
 
179
- const projectPath = isGlobal ? os.homedir() : process.cwd();
180
185
  if (selected !== null) {
181
186
  const deselectedTargets =
182
187
  /** @type {import('./types.js').SupportedTarget[]} */ (
@@ -226,20 +231,32 @@ if (command === 'install-skill') {
226
231
  }
227
232
 
228
233
  if (command === 'install-mcp') {
229
- const argsLeft = [...rest];
230
234
  let isGlobal = true;
235
+ /** @type {string[]} */
236
+ const positionals = [];
231
237
 
232
- const localIdx = argsLeft.indexOf('--local');
233
- if (localIdx !== -1) {
234
- isGlobal = false;
235
- argsLeft.splice(localIdx, 1);
238
+ for (const arg of rest) {
239
+ if (arg === '--local') {
240
+ isGlobal = false;
241
+ continue;
242
+ }
243
+ if (arg === '--global') {
244
+ isGlobal = true;
245
+ continue;
246
+ }
247
+ if (arg.startsWith('--')) {
248
+ process.stderr.write(`Unknown install-mcp option "${arg}".\n`);
249
+ process.exit(1);
250
+ }
251
+ positionals.push(arg);
236
252
  }
237
- const globalIdx = argsLeft.indexOf('--global');
238
- if (globalIdx !== -1) {
239
- argsLeft.splice(globalIdx, 1);
253
+
254
+ if (positionals.length > 1) {
255
+ process.stderr.write(`Unexpected extra argument "${positionals[1]}".\n`);
256
+ process.exit(1);
240
257
  }
241
258
 
242
- const clientArg = argsLeft[0];
259
+ const clientArg = positionals[0];
243
260
 
244
261
  /** @type {import('./types.js').McpClientName[]} */
245
262
  let clients;
@@ -670,7 +687,7 @@ async function main() {
670
687
  process.exitCode = 1;
671
688
  } catch (error) {
672
689
  const message = error instanceof Error ? error.message : String(error);
673
- const raw = error instanceof Error && 'code' in error ? /** @type {any} */ (error).code : '';
690
+ const raw = getErrorCode(error);
674
691
  let code = 'ERROR';
675
692
  if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
676
693
  code = 'DAEMON_OFFLINE';
@@ -692,6 +709,18 @@ async function main() {
692
709
  }
693
710
  }
694
711
 
712
+ /**
713
+ * @param {unknown} error
714
+ * @returns {string}
715
+ */
716
+ function getErrorCode(error) {
717
+ if (!(error instanceof Error) || !('code' in error)) {
718
+ return '';
719
+ }
720
+ const code = /** @type {{ code?: unknown }} */ (error).code;
721
+ return typeof code === 'string' ? code : '';
722
+ }
723
+
695
724
  /**
696
725
  * Allow tests to shrink request timeouts without changing the shared default.
697
726
  *
@@ -15,6 +15,7 @@ import {
15
15
  getBridgeTransport,
16
16
  getSocketPath,
17
17
  } from '../../native-host/src/config.js';
18
+ import { normalizeBridgeAuthToken, readBridgeAuthToken } from '../../native-host/src/auth-token.js';
18
19
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
19
20
 
20
21
  /** @typedef {import('./types.js').BridgeMeta} BridgeMeta */
@@ -56,6 +57,22 @@ function createTimeoutError(method, timeoutMs) {
56
57
  return error;
57
58
  }
58
59
 
60
+ /**
61
+ * @param {net.Socket} socket
62
+ * @param {string} line
63
+ * @returns {Promise<void>}
64
+ */
65
+ async function writeSocketLine(socket, line) {
66
+ if (!socket.write(line)) {
67
+ await Promise.race([
68
+ once(socket, 'drain'),
69
+ once(socket, 'close').then(() => {
70
+ throw new Error('Bridge socket closed while writing.');
71
+ }),
72
+ ]);
73
+ }
74
+ }
75
+
59
76
  export class BridgeClient extends EventEmitter {
60
77
  /**
61
78
  * @param {BridgeClientOptions} [options={}]
@@ -68,6 +85,7 @@ export class BridgeClient extends EventEmitter {
68
85
  autoReconnect = false,
69
86
  restartDaemonOnVersionMismatch = true,
70
87
  restartDaemonFn = restartBridgeDaemon,
88
+ authToken = undefined,
71
89
  } = {}) {
72
90
  super();
73
91
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -78,6 +96,7 @@ export class BridgeClient extends EventEmitter {
78
96
  this.autoReconnect = autoReconnect;
79
97
  this.restartDaemonOnVersionMismatch = restartDaemonOnVersionMismatch;
80
98
  this.restartDaemonFn = restartDaemonFn;
99
+ this.authToken = authToken;
81
100
  this.socket = null;
82
101
  this.connected = false;
83
102
  this.protocolCompatibility = null;
@@ -111,6 +130,18 @@ export class BridgeClient extends EventEmitter {
111
130
  throw error;
112
131
  }
113
132
 
133
+ const registrationPromise = new Promise((resolve, reject) => {
134
+ const timeoutId = setTimeout(() => {
135
+ this.waiting.delete('registered');
136
+ reject(createTimeoutError('register', this.defaultTimeoutMs));
137
+ }, this.defaultTimeoutMs);
138
+ this.waiting.set('registered', {
139
+ resolve,
140
+ reject,
141
+ timeoutId,
142
+ });
143
+ });
144
+
114
145
  parseJsonLines(socket, (raw) => {
115
146
  const message = /** @type {ClientMessage} */ (raw);
116
147
  if (message.type === 'registered') {
@@ -124,6 +155,16 @@ export class BridgeClient extends EventEmitter {
124
155
  return;
125
156
  }
126
157
 
158
+ if (message.type === 'registration_failed') {
159
+ const pending = this.waiting.get('registered');
160
+ if (pending) {
161
+ this.waiting.delete('registered');
162
+ clearTimeout(pending.timeoutId);
163
+ pending.reject(new Error(message.error?.message || 'Bridge daemon registration failed.'));
164
+ }
165
+ return;
166
+ }
167
+
127
168
  if (message.type === 'agent.response') {
128
169
  const pending = this.waiting.get(message.response.id);
129
170
  if (pending) {
@@ -148,20 +189,31 @@ export class BridgeClient extends EventEmitter {
148
189
  // 'close' fires after 'error'; reconnect is triggered there.
149
190
  });
150
191
 
151
- this.socket.write(
152
- `${JSON.stringify({ type: 'register', role: 'agent', clientId: this.clientId })}\n`
153
- );
154
- await new Promise((resolve, reject) => {
155
- const timeoutId = setTimeout(() => {
192
+ const authToken =
193
+ this.authToken === undefined
194
+ ? this.transport.type === 'tcp'
195
+ ? await readBridgeAuthToken()
196
+ : null
197
+ : normalizeBridgeAuthToken(this.authToken);
198
+ try {
199
+ await writeSocketLine(
200
+ socket,
201
+ `${JSON.stringify({
202
+ type: 'register',
203
+ role: 'agent',
204
+ clientId: this.clientId,
205
+ ...(authToken ? { authToken } : {}),
206
+ })}\n`
207
+ );
208
+ } catch (error) {
209
+ const pending = this.waiting.get('registered');
210
+ if (pending) {
211
+ clearTimeout(pending.timeoutId);
156
212
  this.waiting.delete('registered');
157
- reject(createTimeoutError('register', this.defaultTimeoutMs));
158
- }, this.defaultTimeoutMs);
159
- this.waiting.set('registered', {
160
- resolve,
161
- reject,
162
- timeoutId,
163
- });
164
- });
213
+ }
214
+ throw error;
215
+ }
216
+ await registrationPromise;
165
217
 
166
218
  this.protocolCompatibility = null;
167
219
  this.protocolWarning = null;
@@ -244,13 +296,15 @@ export class BridgeClient extends EventEmitter {
244
296
  });
245
297
  });
246
298
 
247
- if (!this.socket.write(`${JSON.stringify({ type: 'agent.request', request })}\n`)) {
248
- await Promise.race([
249
- once(this.socket, 'drain'),
250
- once(this.socket, 'close').then(() => {
251
- throw new Error('Bridge socket closed while writing.');
252
- }),
253
- ]);
299
+ try {
300
+ await writeSocketLine(this.socket, `${JSON.stringify({ type: 'agent.request', request })}\n`);
301
+ } catch (error) {
302
+ const pending = this.waiting.get(request.id);
303
+ if (pending) {
304
+ clearTimeout(pending.timeoutId);
305
+ this.waiting.delete(request.id);
306
+ }
307
+ throw error;
254
308
  }
255
309
  const response = /** @type {BridgeResponse} */ (await responsePromise);
256
310
  return this.attachProtocolWarning(response);
@@ -237,11 +237,10 @@ export const CLI_HELP_SECTIONS = Object.freeze([
237
237
  {
238
238
  title: 'Setup',
239
239
  lines: [
240
- 'bbx install [--browser chrome|edge|brave|chromium|arc] [extension-id] Install native messaging manifest',
240
+ 'bbx install [extension-id] [--browser chrome|edge|brave|chromium|arc] [--all] Install native messaging manifest',
241
241
  'bbx uninstall Remove native host manifests, Browser Bridge runtime files, and managed MCP/skill installs',
242
- 'bbx install [--all] [--browser <name>] [extension-id] Install native host manifest (--all for all supported browsers)',
243
242
  'bbx install-skill [targets|all] [--global] [--project <path>] Install/update the managed Browser Bridge CLI skill',
244
- 'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf',
243
+ 'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
245
244
  'bbx status Check bridge connection',
246
245
  'bbx doctor Diagnose install, daemon, extension, and access readiness',
247
246
  'bbx restart Restart the local Browser Bridge daemon',
@@ -94,29 +94,19 @@ export function getMcpConfigShape(clientName) {
94
94
  * }}
95
95
  */
96
96
  function createBaseServerConfig(clientName) {
97
- const windowsCommand =
98
- process.platform === 'win32'
99
- ? {
100
- command: process.execPath,
101
- args: [mcpServerBinPath],
102
- env: {},
103
- }
104
- : {
105
- command: 'bbx',
106
- args: ['mcp', 'serve'],
107
- env: {},
108
- };
97
+ const serverConfig = {
98
+ command: process.execPath,
99
+ args: [mcpServerBinPath],
100
+ env: {},
101
+ };
109
102
 
110
103
  if (clientName === 'opencode') {
111
104
  return {
112
105
  type: 'local',
113
- command:
114
- process.platform === 'win32'
115
- ? [process.execPath, mcpServerBinPath]
116
- : ['bbx', 'mcp', 'serve'],
106
+ command: [process.execPath, mcpServerBinPath],
117
107
  };
118
108
  }
119
- return windowsCommand;
109
+ return serverConfig;
120
110
  }
121
111
 
122
112
  /** @type {Record<McpClientName, { key: string, includeType: boolean, legacyKeys?: string[], keepEmptyBlock?: boolean }>} */
@@ -142,13 +132,11 @@ const MCP_CONFIG_SHAPES = {
142
132
  */
143
133
  export function buildMcpConfig(clientName) {
144
134
  if (clientName === 'codex') {
145
- const command = process.platform === 'win32' ? process.execPath : 'bbx';
146
- const args = process.platform === 'win32' ? [mcpServerBinPath] : ['mcp', 'serve'];
147
135
  return {
148
136
  mcp_servers: {
149
137
  [BROWSER_BRIDGE_SERVER_NAME]: {
150
- command,
151
- args,
138
+ command: process.execPath,
139
+ args: [mcpServerBinPath],
152
140
  },
153
141
  },
154
142
  };
@@ -277,12 +265,10 @@ export async function getMcpConfigPaths(clientName, options) {
277
265
  * @returns {string}
278
266
  */
279
267
  function formatCodexServerBlock() {
280
- const command = process.platform === 'win32' ? process.execPath : 'bbx';
281
- const args = process.platform === 'win32' ? [mcpServerBinPath] : ['mcp', 'serve'];
282
268
  return [
283
269
  `[mcp_servers."${BROWSER_BRIDGE_SERVER_NAME}"]`,
284
- `command = ${JSON.stringify(command)}`,
285
- `args = ${JSON.stringify(args)}`,
270
+ `command = ${JSON.stringify(process.execPath)}`,
271
+ `args = ${JSON.stringify([mcpServerBinPath])}`,
286
272
  '',
287
273
  ].join('\n');
288
274
  }
@@ -523,8 +509,13 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
523
509
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
524
510
  existing = parsed;
525
511
  }
526
- } catch {
527
- // File missing or unparseable - start fresh.
512
+ } catch (error) {
513
+ if (!isMissingFileError(error)) {
514
+ throw new Error(
515
+ `Cannot update ${configPath}: existing MCP config is not valid JSON. Fix or remove it first.`
516
+ );
517
+ }
518
+ // File missing - start fresh.
528
519
  }
529
520
 
530
521
  const shape = getMcpConfigShape(clientName);
@@ -661,3 +652,15 @@ export function parseInstalledMcpConfig(clientName, raw) {
661
652
  return { configured: false };
662
653
  }
663
654
  }
655
+
656
+ /**
657
+ * @param {unknown} error
658
+ * @returns {boolean}
659
+ */
660
+ function isMissingFileError(error) {
661
+ return Boolean(
662
+ error &&
663
+ typeof error === 'object' &&
664
+ /** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
665
+ );
666
+ }
@@ -160,10 +160,10 @@ export async function checkBrowserManifests() {
160
160
  export async function getDoctorReport(options = {}) {
161
161
  const manifest = await (options.loadManifest || loadInstalledManifest)();
162
162
  const allowedOrigins = Array.isArray(manifest?.allowed_origins) ? manifest.allowed_origins : [];
163
- const manifestInstalled = Boolean(manifest);
164
163
  const defaultExtensionId = options.defaultExtensionIdInfo || resolveDefaultExtensionId();
165
164
 
166
- const browserManifests = await checkBrowserManifests();
165
+ const browserManifests = await (options.checkBrowserManifests || checkBrowserManifests)();
166
+ const manifestInstalled = Boolean(manifest) || browserManifests.some((b) => b.installed);
167
167
 
168
168
  /** @type {DoctorReport} */
169
169
  const report = {
@@ -214,8 +214,6 @@ export async function getDoctorReport(options = {}) {
214
214
  report.extensionConnected = false;
215
215
  }
216
216
 
217
- const browsersWithoutManifest = browserManifests.filter((b) => !b.installed);
218
-
219
217
  if (!report.manifestInstalled) {
220
218
  report.issues.push('native_host_manifest_missing');
221
219
  report.nextSteps.push(
@@ -223,12 +221,6 @@ export async function getDoctorReport(options = {}) {
223
221
  ? 'Run `bbx install` (or `bbx install --all` for all browsers) to install the native host manifest.'
224
222
  : 'Run `bbx install <extension-id>` (or `bbx install --all`) to install the native host manifest.'
225
223
  );
226
- } else if (browsersWithoutManifest.length > 0) {
227
- report.issues.push('native_host_manifest_partial');
228
- const missing = browsersWithoutManifest.map((b) => b.browser).join(', ');
229
- report.nextSteps.push(
230
- `Manifests missing for: ${missing}. Run \`bbx install --all\` to install for all supported browsers.`
231
- );
232
224
  }
233
225
  if (!report.daemonReachable) {
234
226
  report.issues.push('daemon_offline');
@@ -54,13 +54,20 @@ export type ClientMessage =
54
54
  role: 'agent' | 'extension';
55
55
  clientId?: string;
56
56
  }
57
+ | {
58
+ type: 'registration_failed';
59
+ error?: {
60
+ code?: string;
61
+ message?: string;
62
+ };
63
+ }
57
64
  | {
58
65
  type: 'agent.response';
59
66
  response: BridgeResponse;
60
67
  };
61
68
 
62
69
  export interface PendingRequest {
63
- resolve: (value: any) => void;
70
+ resolve: (value: unknown) => void;
64
71
  reject: (error: Error) => void;
65
72
  timeoutId: NodeJS.Timeout;
66
73
  }
@@ -73,6 +80,7 @@ export interface BridgeClientOptions {
73
80
  autoReconnect?: boolean;
74
81
  restartDaemonOnVersionMismatch?: boolean;
75
82
  restartDaemonFn?: typeof restartBridgeDaemon;
83
+ authToken?: string | null;
76
84
  }
77
85
 
78
86
  export interface ShortcutCommand {
@@ -110,6 +118,7 @@ export interface DoctorReport {
110
118
 
111
119
  export interface DoctorReportOptions {
112
120
  loadManifest?: () => Promise<{ allowed_origins?: string[] } | null>;
121
+ checkBrowserManifests?: () => Promise<BrowserManifestStatus[]>;
113
122
  manifestPath?: string;
114
123
  defaultExtensionIdInfo?: { extensionId: string | null; source: string };
115
124
  bridgeClientRunner?: <T>(