@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.
package/README.md CHANGED
@@ -116,7 +116,7 @@ Browser Bridge is optimized for the opposite starting point: **inspect the state
116
116
 
117
117
  On Ubuntu, Chromium is commonly installed as a strict snap, and Flatpak Chromium is similarly sandboxed. If native messaging stays disconnected there, use a non-sandboxed Chromium-based browser such as Google Chrome, Brave, or Edge.
118
118
 
119
- MCP mode is self-contained: the server exposes tools and startup instructions, so a separate CLI skill is not required for MCP guidance.
119
+ MCP mode is self-contained: the dual-era stdio server supports legacy MCP clients and modern stateless MCP revision `2026-07-28`, and exposes tools and matching startup instructions without requiring a separate CLI skill.
120
120
 
121
121
  ## How it works
122
122
 
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.10.0",
3
+ "version": "1.11.1",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -73,26 +73,24 @@
73
73
  "install-manifest": "node packages/native-host/bin/install-manifest.js"
74
74
  },
75
75
  "dependencies": {
76
- "@modelcontextprotocol/sdk": "^1.29.0",
76
+ "@modelcontextprotocol/server": "^2.0.0",
77
77
  "@mozilla/readability": "^0.6.0",
78
78
  "linkedom": "^0.18.13",
79
79
  "zod": "^4.4.3"
80
80
  },
81
81
  "devDependencies": {
82
- "@types/chrome": "^0.2.2",
83
- "@types/node": "^26.1.1",
82
+ "@modelcontextprotocol/client": "^2.0.0",
83
+ "@types/chrome": "^0.2.6",
84
+ "@types/node": "^26.2.0",
84
85
  "ajv": "^8.20.0",
85
86
  "ajv-formats": "^3.0.1",
86
87
  "c8": "^12.0.0",
87
88
  "har-schema": "^2.0.0",
88
- "oxfmt": "^0.60.0",
89
- "oxlint": "^1.75.0",
90
- "tsx": "^4.23.1",
89
+ "oxfmt": "^0.63.0",
90
+ "oxlint": "^1.78.0",
91
+ "tsx": "^4.23.12",
91
92
  "typescript": "^7.0.2"
92
93
  },
93
- "overrides": {
94
- "@hono/node-server": "2.0.11"
95
- },
96
94
  "engines": {
97
95
  "node": ">=20"
98
96
  }
@@ -11,7 +11,7 @@ import {
11
11
  readProxyConfig,
12
12
  } from '../../native-host/src/config.js';
13
13
  import { pingExistingDaemon } from '../../native-host/src/daemon.js';
14
- import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
14
+ import { restartBridgeDaemon, stopBridgeDaemon } from '../../native-host/src/daemon-process.js';
15
15
  import { atomicWriteFile } from './atomic-write.js';
16
16
  import { parseIntArg } from './cli-helpers.js';
17
17
  import {
@@ -44,6 +44,8 @@ export async function handleProxyCommand(args) {
44
44
  randomUUID
45
45
  );
46
46
  assertProxyBindSafety(existing, options, bindHost);
47
+ // Stop against the current config before changing its port or auth token.
48
+ const stopResult = await stopBridgeDaemon();
47
49
  await atomicWriteFile(getBridgeAuthTokenPath(), `${token}\n`, {
48
50
  encoding: 'utf8',
49
51
  mode: 0o600,
@@ -75,7 +77,7 @@ export async function handleProxyCommand(args) {
75
77
  '',
76
78
  `Token: ${token}${tokenNote}`,
77
79
  `Config: ${configPath}`,
78
- `Daemon: ${result.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
80
+ `Daemon: ${stopResult.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
79
81
  ];
80
82
  if (tokenSource !== 'existing') {
81
83
  if (isLoopbackHost(bindHost)) {
@@ -106,10 +108,11 @@ export async function handleProxyCommand(args) {
106
108
 
107
109
  if (subcommand === 'disable') {
108
110
  const configPath = getProxyConfigPath();
111
+ const stopResult = await stopBridgeDaemon();
109
112
  await fs.promises.rm(configPath, { force: true });
110
- const result = await restartBridgeDaemon();
113
+ await restartBridgeDaemon();
111
114
  process.stdout.write(
112
- `Browser Bridge proxy disabled. Daemon ${result.previouslyRunning ? 'restarted' : 'started'}.\n`
115
+ `Browser Bridge proxy disabled. Daemon ${stopResult.previouslyRunning ? 'restarted' : 'started'}.\n`
113
116
  );
114
117
  return;
115
118
  }
@@ -267,7 +267,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
267
267
  'bbx uninstall Remove native host manifests, Browser Bridge runtime files, and managed MCP/skill installs',
268
268
  'bbx install-skill [targets|all] [--global] [--project <path>] Install/update the managed Browser Bridge CLI skill',
269
269
  'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
270
- ' MCP starts with common tools; browser_toolset loads one specialized typed tool by name when needed.',
270
+ ' MCP uses a compact surface: legacy clients can load typed tools; modern stateless clients use browser_call.',
271
271
  'bbx status Check bridge connection',
272
272
  'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
273
273
  'bbx restart Restart the local daemon and running MCP servers',
@@ -14,6 +14,8 @@ const DEFAULT_LOCK_TIMEOUT_MS = 120_000;
14
14
  const UPDATE_LOCK_PORT_START = 49_152;
15
15
  const UPDATE_LOCK_PORT_COUNT = 16_384;
16
16
  const UPDATE_LOCK_PORT_ATTEMPTS = 32;
17
+ const UPDATE_LOCK_PORT_STEP = UPDATE_LOCK_PORT_COUNT / UPDATE_LOCK_PORT_ATTEMPTS;
18
+ const UNAVAILABLE_LOCK_PORT_CODES = new Set(['EACCES', 'EADDRINUSE']);
17
19
 
18
20
  /** @typedef {import('./types.js').NpmUpdateResult} NpmUpdateResult */
19
21
 
@@ -113,6 +115,7 @@ export async function runNpmCommand(args, options = {}) {
113
115
  ? process.execPath
114
116
  : npmExecPath || (process.platform === 'win32' ? 'npm.cmd' : 'npm');
115
117
  const commandArgs = useNode && npmExecPath ? [npmExecPath, ...args] : args;
118
+ const useWindowsShell = process.platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command);
116
119
 
117
120
  return new Promise((resolve, reject) => {
118
121
  execFile(
@@ -124,6 +127,7 @@ export async function runNpmCommand(args, options = {}) {
124
127
  timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
125
128
  maxBuffer: 4 * 1024 * 1024,
126
129
  windowsHide: true,
130
+ shell: useWindowsShell,
127
131
  },
128
132
  (error, stdout, stderr) => {
129
133
  if (!error) {
@@ -154,7 +158,8 @@ export function getNpmUpdateLockPort(lockKey, offset = 0) {
154
158
  const digest = createHash('sha256').update(lockKey).digest();
155
159
  return (
156
160
  UPDATE_LOCK_PORT_START +
157
- ((digest.readUInt32BE(0) + Math.max(0, Math.trunc(offset))) % UPDATE_LOCK_PORT_COUNT)
161
+ ((digest.readUInt32BE(0) + Math.max(0, Math.trunc(offset)) * UPDATE_LOCK_PORT_STEP) %
162
+ UPDATE_LOCK_PORT_COUNT)
158
163
  );
159
164
  }
160
165
 
@@ -200,7 +205,9 @@ export async function acquireNpmUpdateLock(lockKey, timeoutMs = DEFAULT_LOCK_TIM
200
205
  const server = net.createServer((socket) => socket.end(`${identity}\n`));
201
206
  const acquired = await new Promise((resolve, reject) => {
202
207
  server.once('error', (error) => {
203
- if (/** @type {NodeJS.ErrnoException} */ (error).code === 'EADDRINUSE') {
208
+ if (
209
+ UNAVAILABLE_LOCK_PORT_CODES.has(/** @type {NodeJS.ErrnoException} */ (error).code ?? '')
210
+ ) {
204
211
  resolve(false);
205
212
  return;
206
213
  }
@@ -106,7 +106,7 @@ export async function ensureClientConnected(client) {
106
106
  * @param {BridgeClient} client
107
107
  * @param {BridgeMethod} method
108
108
  * @param {Record<string, unknown>} [params={}]
109
- * @param {{ tabId?: number | null, source?: BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
109
+ * @param {{ tabId?: number | null, source?: BridgeRequestSource, mcpEra?: import('./types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
110
110
  * @returns {Promise<BridgeResponse>}
111
111
  */
112
112
  export async function requestBridge(client, method, params = {}, options = {}) {
@@ -127,7 +127,12 @@ export async function requestBridge(client, method, params = {}, options = {}) {
127
127
  method,
128
128
  params,
129
129
  tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
130
- meta: withRequestMeta(options.source, options.tokenBudget, options.automaticRetry),
130
+ meta: withRequestMeta(
131
+ options.source,
132
+ options.mcpEra,
133
+ options.tokenBudget,
134
+ options.automaticRetry
135
+ ),
131
136
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
132
137
  });
133
138
  }
@@ -168,16 +173,20 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
168
173
 
169
174
  /**
170
175
  * @param {BridgeRequestSource | undefined} source
176
+ * @param {import('./types.js').McpProtocolEra | undefined} mcpEra
171
177
  * @param {number | null | undefined} tokenBudget
172
178
  * @param {'mcp_second_attempt' | undefined} automaticRetry
173
179
  * @returns {BridgeMeta}
174
180
  */
175
- function withRequestMeta(source, tokenBudget, automaticRetry) {
181
+ function withRequestMeta(source, mcpEra, tokenBudget, automaticRetry) {
176
182
  /** @type {BridgeMeta} */
177
183
  const meta = {};
178
184
  if (source) {
179
185
  meta.source = source;
180
186
  }
187
+ if (source === 'mcp' && mcpEra) {
188
+ meta.mcp_era = mcpEra;
189
+ }
181
190
  if (typeof tokenBudget === 'number' && Number.isFinite(tokenBudget)) {
182
191
  meta.token_budget = tokenBudget;
183
192
  }
@@ -4,6 +4,7 @@ import type {
4
4
  BridgeMethod,
5
5
  BridgeRequestSource,
6
6
  BridgeResponse,
7
+ McpProtocolEra,
7
8
  RecoveryEventKind,
8
9
  RecoveryTelemetrySummary,
9
10
  ScreenshotResult,
@@ -17,6 +18,7 @@ export type {
17
18
  BridgeRequestSource,
18
19
  BridgeResponse,
19
20
  BridgeTransport,
21
+ McpProtocolEra,
20
22
  ScreenshotResult,
21
23
  };
22
24
 
@@ -9,7 +9,7 @@ import { startBridgeMcpServer } from './server.js';
9
9
 
10
10
  /**
11
11
  * @typedef {{
12
- * start?: () => Promise<void>,
12
+ * start?: () => void | Promise<void>,
13
13
  * startControl?: typeof tryStartMcpProcessControl,
14
14
  * argv?: string[],
15
15
  * stdout?: { write: (chunk: string) => unknown },
@@ -29,20 +29,26 @@ const WORKFLOW_INSTRUCTIONS = Object.freeze([
29
29
  const SERVER_HEADER =
30
30
  "Browser Bridge MCP inspects and interacts with the user's real Chrome tab through a compact tool set.";
31
31
 
32
- const TOOL_RULES = Object.freeze([
32
+ const LEGACY_TOOL_RULES = Object.freeze([
33
33
  'The common tools are available immediately. When a specialized typed tool is useful, call browser_toolset with its exact tool name; browser_call always reaches every bridge method without loading another tool.',
34
34
  'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
35
35
  ]);
36
36
 
37
+ const MODERN_TOOL_RULES = Object.freeze([
38
+ 'The compact tool list is static for stateless MCP. browser_call reaches every bridge method, and browser_skill returns runtime groups and limits.',
39
+ 'Use browser_call method protocol.describe with method or group params to load unfamiliar signatures cheaply.',
40
+ ]);
41
+
37
42
  /**
38
- * Build instructions for progressive tool discovery.
43
+ * Build instructions for the negotiated MCP era.
39
44
  *
45
+ * @param {'legacy' | 'modern'} [era='legacy']
40
46
  * @returns {string}
41
47
  */
42
- export function getMcpServerInstructions() {
43
- return [SERVER_HEADER, ...TOOL_RULES, ...SHARED_INSTRUCTIONS, ...WORKFLOW_INSTRUCTIONS].join(
44
- '\n'
45
- );
48
+ export function getMcpServerInstructions(era = 'legacy') {
49
+ const toolRules = era === 'modern' ? MODERN_TOOL_RULES : LEGACY_TOOL_RULES;
50
+ return [SERVER_HEADER, ...toolRules, ...SHARED_INSTRUCTIONS, ...WORKFLOW_INSTRUCTIONS].join('\n');
46
51
  }
47
52
 
48
53
  export const MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions();
54
+ export const MODERN_MCP_SERVER_INSTRUCTIONS = getMcpServerInstructions('modern');
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
 
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { randomUUID } from 'node:crypto';
4
5
 
5
6
  import {
@@ -46,6 +47,18 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
46
47
 
47
48
  export const REQUEST_SOURCE = 'mcp';
48
49
  const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
50
+ /** @type {AsyncLocalStorage<import('../../protocol/src/types.js').McpProtocolEra>} */
51
+ const MCP_REQUEST_ERA = new AsyncLocalStorage();
52
+
53
+ /**
54
+ * @template T
55
+ * @param {import('../../protocol/src/types.js').McpProtocolEra} era
56
+ * @param {() => T} callback
57
+ * @returns {T}
58
+ */
59
+ export function runWithMcpRequestEra(era, callback) {
60
+ return MCP_REQUEST_ERA.run(era, callback);
61
+ }
49
62
 
50
63
  /** @type {ReadonlySet<BridgeMethod>} */
51
64
  const RETRY_SAFE_METHODS = new Set([
@@ -606,14 +619,18 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
606
619
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
607
620
  * @param {BridgeMethod} method
608
621
  * @param {Record<string, unknown>} params
609
- * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
622
+ * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
610
623
  * @returns {Promise<BridgeResponse>}
611
624
  */
612
625
  export async function requestBridgeWithRetry(client, method, params, options) {
626
+ const requestOptions = {
627
+ ...options,
628
+ mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore(),
629
+ };
613
630
  /** @type {BridgeResponse} */
614
631
  let response;
615
632
  try {
616
- response = await requestBridge(client, method, params, options);
633
+ response = await requestBridge(client, method, params, requestOptions);
617
634
  } catch (error) {
618
635
  if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
619
636
  throw error;
@@ -625,7 +642,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
625
642
  throw error;
626
643
  }
627
644
  return requestBridge(client, method, params, {
628
- ...options,
645
+ ...requestOptions,
629
646
  automaticRetry: 'mcp_second_attempt',
630
647
  });
631
648
  }
@@ -640,7 +657,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
640
657
  );
641
658
  await new Promise((r) => setTimeout(r, delay));
642
659
  return requestBridge(client, method, params, {
643
- ...options,
660
+ ...requestOptions,
644
661
  automaticRetry: 'mcp_second_attempt',
645
662
  });
646
663
  }
@@ -11,6 +11,7 @@ import {
11
11
  getToolTokenBudget,
12
12
  getBridgeDestinations,
13
13
  applyLimitBudgetPreset,
14
+ requestBridgeWithRetry,
14
15
  summarizeToolError,
15
16
  summarizeToolResponse,
16
17
  withToolClient,
@@ -34,6 +35,7 @@ export {
34
35
  callBridgeTool,
35
36
  dispatchToolAction,
36
37
  REQUEST_SOURCE,
38
+ runWithMcpRequestEra,
37
39
  } from './handlers-utils.js';
38
40
 
39
41
  export {
@@ -233,10 +235,14 @@ export async function handleHealthTool(args = {}) {
233
235
  const destinationId = typeof args.destinationId === 'string' ? args.destinationId : null;
234
236
  return withToolClient(
235
237
  async (client) => {
236
- const response = await client.request({
237
- method: 'health.ping',
238
- meta: { source: REQUEST_SOURCE },
239
- });
238
+ const response = await requestBridgeWithRetry(
239
+ client,
240
+ 'health.ping',
241
+ {},
242
+ {
243
+ source: REQUEST_SOURCE,
244
+ }
245
+ );
240
246
  return summarizeToolResponse(response, 'health.ping');
241
247
  },
242
248
  { destinationId }