@browserbridge/bbx 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -20,6 +20,7 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
20
20
  import { uninstallNativeManifest } from '../../native-host/src/install-manifest.js';
21
21
  import {
22
22
  createRuntimeContext,
23
+ getProtocolVersion,
23
24
  METHODS,
24
25
  summarizeBatchErrorItem,
25
26
  summarizeBatchResponseItem,
@@ -628,7 +629,7 @@ async function main() {
628
629
  evidence: null,
629
630
  durationMs: 0,
630
631
  approxTokens: 0,
631
- meta: { protocol_version: '1.0' },
632
+ meta: { protocol_version: getProtocolVersion() },
632
633
  error: {
633
634
  code: 'INVALID_REQUEST',
634
635
  message: 'Each batch call needs a method.',
@@ -645,7 +646,7 @@ async function main() {
645
646
  evidence: null,
646
647
  durationMs: 0,
647
648
  approxTokens: 0,
648
- meta: { protocol_version: '1.0' },
649
+ meta: { protocol_version: getProtocolVersion() },
649
650
  error: {
650
651
  code: 'INVALID_REQUEST',
651
652
  message: `Unknown bridge method "${call.method}".`,
@@ -1,14 +1,16 @@
1
1
  // @ts-check
2
2
 
3
3
  import { EventEmitter, once } from 'node:events';
4
+ import fs from 'node:fs';
4
5
  import net from 'node:net';
5
6
  import { randomUUID } from 'node:crypto';
6
7
 
7
8
  import {
8
9
  createRequest,
9
10
  DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
10
- PROTOCOL_VERSION,
11
+ getProtocolVersion,
11
12
  parseJsonLines,
13
+ setProtocolPackageVersion,
12
14
  } from '../../protocol/src/index.js';
13
15
  import {
14
16
  createSocketBridgeTransport,
@@ -26,6 +28,21 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
26
28
  /** @typedef {import('./types.js').PendingRequest} PendingRequest */
27
29
  /** @typedef {import('./types.js').ProtocolHealthResult} ProtocolHealthResult */
28
30
 
31
+ setProtocolPackageVersion(loadPackageVersion());
32
+
33
+ /**
34
+ * @returns {string | null}
35
+ */
36
+ function loadPackageVersion() {
37
+ try {
38
+ const raw = fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8');
39
+ const parsed = JSON.parse(raw);
40
+ return parsed && typeof parsed.version === 'string' ? parsed.version : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
29
46
  /**
30
47
  * @param {string} left
31
48
  * @param {string} right
@@ -57,6 +74,18 @@ function createTimeoutError(method, timeoutMs) {
57
74
  return error;
58
75
  }
59
76
 
77
+ /**
78
+ * npm exec often runs a checkout-local CLI against an already-connected global
79
+ * daemon. In that mode, protocol drift should warn instead of replacing the
80
+ * user's working daemon and temporarily disconnecting the extension.
81
+ *
82
+ * @param {NodeJS.ProcessEnv} [env=process.env]
83
+ * @returns {boolean}
84
+ */
85
+ function shouldAutoRestartDaemonOnVersionMismatch(env = process.env) {
86
+ return env.npm_command !== 'exec';
87
+ }
88
+
60
89
  /**
61
90
  * @param {net.Socket} socket
62
91
  * @param {string} line
@@ -83,7 +112,7 @@ export class BridgeClient extends EventEmitter {
83
112
  clientId = `agent_${randomUUID()}`,
84
113
  defaultTimeoutMs = DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
85
114
  autoReconnect = false,
86
- restartDaemonOnVersionMismatch = true,
115
+ restartDaemonOnVersionMismatch = shouldAutoRestartDaemonOnVersionMismatch(),
87
116
  restartDaemonFn = restartBridgeDaemon,
88
117
  authToken = undefined,
89
118
  } = {}) {
@@ -390,9 +419,16 @@ export class BridgeClient extends EventEmitter {
390
419
  ? healthResult.supported_versions
391
420
  : [];
392
421
  const latestRemote = remoteVersions[0];
422
+ if (
423
+ healthResult?.extensionConnected === true &&
424
+ Array.isArray(healthResult.supported_versions) &&
425
+ !healthResult.supported_versions.includes(getProtocolVersion())
426
+ ) {
427
+ return false;
428
+ }
393
429
  return (
394
430
  typeof latestRemote === 'string' &&
395
- compareProtocolVersions(latestRemote, PROTOCOL_VERSION) < 0
431
+ compareProtocolVersions(latestRemote, getProtocolVersion()) < 0
396
432
  );
397
433
  }
398
434
 
@@ -436,20 +472,21 @@ export class BridgeClient extends EventEmitter {
436
472
  if (remoteVersions.length === 0) {
437
473
  return {
438
474
  compatible: true,
439
- localVersion: PROTOCOL_VERSION,
475
+ localVersion: getProtocolVersion(),
440
476
  remoteVersions,
441
477
  };
442
478
  }
443
- const compatible = remoteVersions.includes(PROTOCOL_VERSION);
479
+ const localVersion = getProtocolVersion();
480
+ const compatible = remoteVersions.includes(localVersion);
444
481
  return {
445
482
  compatible,
446
- localVersion: PROTOCOL_VERSION,
483
+ localVersion,
447
484
  remoteVersions,
448
485
  ...(!compatible && {
449
486
  warning:
450
487
  typeof healthResult?.migration_hint === 'string' && healthResult.migration_hint
451
488
  ? healthResult.migration_hint
452
- : `Protocol mismatch: client speaks ${PROTOCOL_VERSION} but remote supports [${remoteVersions.join(', ')}]. Update the ${remoteVersions[0] > PROTOCOL_VERSION ? 'client (npm)' : 'extension'} to match.`,
489
+ : `Protocol mismatch: client speaks ${localVersion} but remote supports [${remoteVersions.join(', ')}]. Update the ${remoteVersions[0] > localVersion ? 'client (npm)' : 'extension'} to match.`,
453
490
  }),
454
491
  };
455
492
  }
@@ -3,7 +3,6 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
6
 
8
7
  /** @typedef {import('./types.js').McpClientName} McpClientName */
9
8
 
@@ -40,8 +39,8 @@ export function isMcpClientName(value) {
40
39
  }
41
40
 
42
41
  const BROWSER_BRIDGE_SERVER_NAME = 'browser-bridge';
43
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
44
- const mcpServerBinPath = path.join(packageRoot, 'packages', 'mcp-server', 'src', 'bin.js');
42
+ const MCP_COMMAND = 'bbx';
43
+ const MCP_ARGS = Object.freeze(['mcp', 'serve']);
45
44
 
46
45
  /**
47
46
  * @returns {string}
@@ -95,15 +94,15 @@ export function getMcpConfigShape(clientName) {
95
94
  */
96
95
  function createBaseServerConfig(clientName) {
97
96
  const serverConfig = {
98
- command: process.execPath,
99
- args: [mcpServerBinPath],
97
+ command: MCP_COMMAND,
98
+ args: [...MCP_ARGS],
100
99
  env: {},
101
100
  };
102
101
 
103
102
  if (clientName === 'opencode') {
104
103
  return {
105
104
  type: 'local',
106
- command: [process.execPath, mcpServerBinPath],
105
+ command: [MCP_COMMAND, ...MCP_ARGS],
107
106
  };
108
107
  }
109
108
  return serverConfig;
@@ -135,8 +134,8 @@ export function buildMcpConfig(clientName) {
135
134
  return {
136
135
  mcp_servers: {
137
136
  [BROWSER_BRIDGE_SERVER_NAME]: {
138
- command: process.execPath,
139
- args: [mcpServerBinPath],
137
+ command: MCP_COMMAND,
138
+ args: [...MCP_ARGS],
140
139
  },
141
140
  },
142
141
  };
@@ -267,8 +266,8 @@ export async function getMcpConfigPaths(clientName, options) {
267
266
  function formatCodexServerBlock() {
268
267
  return [
269
268
  `[mcp_servers."${BROWSER_BRIDGE_SERVER_NAME}"]`,
270
- `command = ${JSON.stringify(process.execPath)}`,
271
- `args = ${JSON.stringify([mcpServerBinPath])}`,
269
+ `command = ${JSON.stringify(MCP_COMMAND)}`,
270
+ `args = ${JSON.stringify(MCP_ARGS)}`,
272
271
  '',
273
272
  ].join('\n');
274
273
  }
@@ -1,13 +1,22 @@
1
1
  // @ts-check
2
2
 
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
 
6
7
  import {
7
8
  APP_NAME,
9
+ getBridgeDir,
10
+ getDaemonLogPath,
11
+ getDaemonPidPath,
8
12
  getManifestInstallDir,
13
+ getSocketPath,
9
14
  SUPPORTED_BROWSERS,
10
15
  } from '../../native-host/src/config.js';
16
+ import {
17
+ readDaemonStartHistory,
18
+ summarizeDaemonRestarts,
19
+ } from '../../native-host/src/daemon-process.js';
11
20
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
12
21
  import { methodNeedsTab } from './cli-helpers.js';
13
22
  import { BridgeClient } from './client.js';
@@ -18,6 +27,7 @@ import { BridgeClient } from './client.js';
18
27
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
19
28
  /** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
20
29
  /** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
30
+ /** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
21
31
  /** @typedef {import('./types.js').DoctorReport} DoctorReport */
22
32
  /** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
23
33
 
@@ -156,6 +166,195 @@ export async function checkBrowserManifests() {
156
166
  );
157
167
  }
158
168
 
169
+ /**
170
+ * @param {string} line
171
+ * @param {number} startIndex
172
+ * @returns {{ value: string, nextIndex: number } | null}
173
+ */
174
+ function readShellSingleQuotedToken(line, startIndex) {
175
+ let index = startIndex;
176
+ while (line[index] === ' ') {
177
+ index += 1;
178
+ }
179
+ if (line[index] !== "'") {
180
+ return null;
181
+ }
182
+ index += 1;
183
+
184
+ let value = '';
185
+ while (index < line.length) {
186
+ const endIndex = line.indexOf("'", index);
187
+ if (endIndex === -1) {
188
+ return null;
189
+ }
190
+ value += line.slice(index, endIndex);
191
+ index = endIndex + 1;
192
+ if (line.startsWith("\\''", index)) {
193
+ value += "'";
194
+ index += 3;
195
+ continue;
196
+ }
197
+ return { value, nextIndex: index };
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * @param {string} launcher
205
+ * @returns {{ nodePath: string, hostPath: string } | null}
206
+ */
207
+ function parseNativeHostLauncherTargets(launcher) {
208
+ const windowsMatch = /"([^"\r\n]+)"\s+"([^"\r\n]+)"\s+%\*/u.exec(launcher);
209
+ if (windowsMatch?.[1] && windowsMatch[2]) {
210
+ return {
211
+ nodePath: windowsMatch[1],
212
+ hostPath: windowsMatch[2],
213
+ };
214
+ }
215
+
216
+ const execLine = launcher
217
+ .split(/\r?\n/u)
218
+ .map((line) => line.trim())
219
+ .find((line) => line.startsWith('exec '));
220
+ if (!execLine) {
221
+ return null;
222
+ }
223
+ const nodeToken = readShellSingleQuotedToken(execLine, 'exec '.length);
224
+ if (!nodeToken) {
225
+ return null;
226
+ }
227
+ const hostToken = readShellSingleQuotedToken(execLine, nodeToken.nextIndex);
228
+ if (!hostToken) {
229
+ return null;
230
+ }
231
+ return {
232
+ nodePath: nodeToken.value,
233
+ hostPath: hostToken.value,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Validate installed native messaging manifests and the launcher they point to.
239
+ * This catches stale sudo/global Node installs where the manifest exists but
240
+ * Chrome launches a missing or non-executable script/Node binary.
241
+ *
242
+ * @param {BrowserManifestStatus[]} browserManifests
243
+ * @returns {Promise<NativeHostManifestIssue[]>}
244
+ */
245
+ export async function checkNativeHostManifestHealth(browserManifests) {
246
+ /** @type {NativeHostManifestIssue[]} */
247
+ const issues = [];
248
+ const executableAccessMode = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
249
+ for (const entry of browserManifests) {
250
+ if (!entry.installed) {
251
+ continue;
252
+ }
253
+
254
+ /** @type {Record<string, unknown>} */
255
+ let manifest;
256
+ try {
257
+ const rawManifest = await fs.promises.readFile(entry.manifestPath, 'utf8');
258
+ const parsed = JSON.parse(rawManifest);
259
+ manifest = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
260
+ } catch (error) {
261
+ issues.push({
262
+ browser: entry.browser,
263
+ manifestPath: entry.manifestPath,
264
+ message: `Native host manifest is not readable JSON: ${error instanceof Error ? error.message : String(error)}`,
265
+ });
266
+ continue;
267
+ }
268
+
269
+ const launcherPath = typeof manifest.path === 'string' ? manifest.path : '';
270
+ if (!launcherPath) {
271
+ issues.push({
272
+ browser: entry.browser,
273
+ manifestPath: entry.manifestPath,
274
+ message: 'Native host manifest does not specify a launcher path.',
275
+ });
276
+ continue;
277
+ }
278
+
279
+ let launcherRaw = '';
280
+ try {
281
+ await fs.promises.access(launcherPath, executableAccessMode);
282
+ launcherRaw = await fs.promises.readFile(launcherPath, 'utf8');
283
+ } catch (error) {
284
+ issues.push({
285
+ browser: entry.browser,
286
+ manifestPath: entry.manifestPath,
287
+ message: `Native host launcher is not executable or readable at ${launcherPath}: ${error instanceof Error ? error.message : String(error)}`,
288
+ });
289
+ continue;
290
+ }
291
+
292
+ const launcherTargets = parseNativeHostLauncherTargets(launcherRaw);
293
+ if (!launcherTargets) {
294
+ issues.push({
295
+ browser: entry.browser,
296
+ manifestPath: entry.manifestPath,
297
+ message: `Native host launcher has an unrecognized format at ${launcherPath}. Run \`bbx install --browser ${entry.browser}\` to regenerate it.`,
298
+ });
299
+ continue;
300
+ }
301
+
302
+ try {
303
+ await fs.promises.access(launcherTargets.nodePath, executableAccessMode);
304
+ } catch (error) {
305
+ issues.push({
306
+ browser: entry.browser,
307
+ manifestPath: entry.manifestPath,
308
+ message: `Node executable referenced by the native host launcher is not usable at ${launcherTargets.nodePath}: ${error instanceof Error ? error.message : String(error)}`,
309
+ });
310
+ }
311
+
312
+ try {
313
+ await fs.promises.access(launcherTargets.hostPath, fs.constants.F_OK);
314
+ } catch (error) {
315
+ issues.push({
316
+ browser: entry.browser,
317
+ manifestPath: entry.manifestPath,
318
+ message: `Native host script referenced by the launcher is missing at ${launcherTargets.hostPath}: ${error instanceof Error ? error.message : String(error)}`,
319
+ });
320
+ }
321
+ }
322
+ return issues;
323
+ }
324
+
325
+ /**
326
+ * Check the bridge home dir and the daemon files inside it for writability.
327
+ * Root-owned files (typically left behind by a sudo install or a sudo `bbx`
328
+ * run) make the daemon crash-loop, so doctor must call them out explicitly.
329
+ *
330
+ * @returns {Promise<string[]>} paths that exist but are not writable
331
+ */
332
+ export async function checkUnwritableBridgePaths() {
333
+ // The root-owned-files scenario comes from sudo installs, which do not exist
334
+ // on Windows — and fs.access(W_OK) there only reflects the read-only file
335
+ // attribute, so it would produce false positives.
336
+ if (os.platform() === 'win32') {
337
+ return [];
338
+ }
339
+
340
+ const candidates = [getBridgeDir(), getDaemonPidPath(), getDaemonLogPath()];
341
+ candidates.push(getSocketPath());
342
+
343
+ /** @type {string[]} */
344
+ const unwritable = [];
345
+ for (const candidate of candidates) {
346
+ try {
347
+ await fs.promises.access(candidate, fs.constants.W_OK);
348
+ } catch (error) {
349
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
350
+ if (code !== 'ENOENT') {
351
+ unwritable.push(candidate);
352
+ }
353
+ }
354
+ }
355
+ return unwritable;
356
+ }
357
+
159
358
  /**
160
359
  * @param {DoctorReportOptions} [options={}]
161
360
  * @returns {Promise<DoctorReport>}
@@ -166,6 +365,15 @@ export async function getDoctorReport(options = {}) {
166
365
  const defaultExtensionId = options.defaultExtensionIdInfo || resolveDefaultExtensionId();
167
366
 
168
367
  const browserManifests = await (options.checkBrowserManifests || checkBrowserManifests)();
368
+ const nativeHostManifestIssues = await (
369
+ options.checkNativeHostManifestHealth || checkNativeHostManifestHealth
370
+ )(browserManifests);
371
+ const daemonRestarts = summarizeDaemonRestarts(
372
+ await (options.readDaemonStartHistory || readDaemonStartHistory)()
373
+ );
374
+ const unwritableBridgePaths = await (
375
+ options.checkUnwritableBridgePaths || checkUnwritableBridgePaths
376
+ )();
169
377
  const manifestInstalled = Boolean(manifest) || browserManifests.some((b) => b.installed);
170
378
  const chromiumSandboxedManifestInstalled = browserManifests.some(
171
379
  (entry) =>
@@ -188,6 +396,10 @@ export async function getDoctorReport(options = {}) {
188
396
  routeTabId: null,
189
397
  routeReady: false,
190
398
  routeReason: 'access_disabled',
399
+ daemonRestarts,
400
+ daemonLogPath: getDaemonLogPath(),
401
+ unwritableBridgePaths,
402
+ nativeHostManifestIssues,
191
403
  issues: [],
192
404
  nextSteps: [],
193
405
  browserManifests,
@@ -223,6 +435,24 @@ export async function getDoctorReport(options = {}) {
223
435
  report.extensionConnected = false;
224
436
  }
225
437
 
438
+ if (unwritableBridgePaths.length > 0) {
439
+ report.issues.push('bridge_files_not_writable');
440
+ report.nextSteps.push(
441
+ `These Browser Bridge files are not writable by the current user (usually caused by installing or running bbx with sudo): ${unwritableBridgePaths.join(', ')}. Fix ownership with: sudo chown -R "$USER" "${getBridgeDir()}"`
442
+ );
443
+ }
444
+ if (nativeHostManifestIssues.length > 0) {
445
+ report.issues.push('native_host_manifest_invalid');
446
+ report.nextSteps.push(
447
+ `The native host manifest or launcher is broken: ${nativeHostManifestIssues.map((issue) => issue.message).join(' ')} Reinstall the native host with \`bbx install --browser <browser>\` or \`bbx install --all\`.`
448
+ );
449
+ }
450
+ if (daemonRestarts.restartLoop) {
451
+ report.issues.push('daemon_restart_loop');
452
+ report.nextSteps.push(
453
+ `The bridge daemon started ${daemonRestarts.startsInWindow} times in the last ${Math.round(daemonRestarts.windowMs / 1000)}s, which means it keeps crashing shortly after startup. Check the daemon log for the underlying error: ${report.daemonLogPath}`
454
+ );
455
+ }
226
456
  if (!report.manifestInstalled) {
227
457
  report.issues.push('native_host_manifest_missing');
228
458
  report.nextSteps.push(
@@ -98,6 +98,18 @@ export interface BrowserManifestStatus {
98
98
  installed: boolean;
99
99
  }
100
100
 
101
+ export interface DaemonRestartSummary {
102
+ startsInWindow: number;
103
+ windowMs: number;
104
+ restartLoop: boolean;
105
+ }
106
+
107
+ export interface NativeHostManifestIssue {
108
+ browser: string;
109
+ manifestPath: string;
110
+ message: string;
111
+ }
112
+
101
113
  export interface DoctorReport {
102
114
  manifestInstalled: boolean;
103
115
  manifestPath: string;
@@ -111,6 +123,10 @@ export interface DoctorReport {
111
123
  routeTabId: number | null;
112
124
  routeReady: boolean;
113
125
  routeReason: string;
126
+ daemonRestarts: DaemonRestartSummary;
127
+ daemonLogPath: string;
128
+ unwritableBridgePaths: string[];
129
+ nativeHostManifestIssues: NativeHostManifestIssue[];
114
130
  issues: string[];
115
131
  nextSteps: string[];
116
132
  browserManifests: BrowserManifestStatus[];
@@ -124,6 +140,11 @@ export interface DoctorReportOptions {
124
140
  bridgeClientRunner?: <T>(
125
141
  callback: (client: { request: BridgeClientRequest }) => Promise<T>
126
142
  ) => Promise<T>;
143
+ readDaemonStartHistory?: () => Promise<number[]>;
144
+ checkUnwritableBridgePaths?: () => Promise<string[]>;
145
+ checkNativeHostManifestHealth?: (
146
+ browserManifests: BrowserManifestStatus[]
147
+ ) => Promise<NativeHostManifestIssue[]>;
127
148
  }
128
149
 
129
150
  export type BridgeClientRequest = (options: {
@@ -7,7 +7,11 @@ import {
7
7
  getBridgeTransport,
8
8
  } from '../src/config.js';
9
9
  import { DaemonLogger } from '../src/daemon-logger.js';
10
- import { clearDaemonPidFile, writeDaemonPidFile } from '../src/daemon-process.js';
10
+ import {
11
+ clearDaemonPidFile,
12
+ recordDaemonStart,
13
+ writeDaemonPidFile,
14
+ } from '../src/daemon-process.js';
11
15
 
12
16
  applyWindowsTcpTransportDefaults();
13
17
  const transport = getBridgeTransport();
@@ -25,7 +29,6 @@ function isExistingDaemonError(error) {
25
29
 
26
30
  try {
27
31
  await daemon.start();
28
- await writeDaemonPidFile(process.pid);
29
32
  } catch (error) {
30
33
  if (isExistingDaemonError(error)) {
31
34
  process.stdout.write(`${error.message}\n`);
@@ -35,6 +38,18 @@ try {
35
38
  process.exit(1);
36
39
  }
37
40
 
41
+ await recordDaemonStart();
42
+
43
+ try {
44
+ await writeDaemonPidFile(process.pid);
45
+ } catch (error) {
46
+ // A stale root-owned pid file (e.g. after a sudo install) must not take down
47
+ // a daemon that is already listening; `bbx` falls back to lsof for the pid.
48
+ process.stderr.write(
49
+ `Could not write daemon pid file: ${error instanceof Error ? error.message : String(error)}\n`
50
+ );
51
+ }
52
+
38
53
  process.stdout.write(`Browser Bridge daemon listening on ${formatBridgeTransport(transport)}\n`);
39
54
 
40
55
  let shuttingDown = false;
@@ -16,6 +16,18 @@ import { installNativeManifest } from '../src/install-manifest.js';
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const repoRoot = path.resolve(__dirname, '../../..');
18
18
 
19
+ /**
20
+ * npm exec may run package lifecycle hooks while resolving the local bin. Keep
21
+ * transient `npm exec -- bbx ...` invocations from rewriting host setup or
22
+ * replacing a working daemon.
23
+ *
24
+ * @param {NodeJS.ProcessEnv} [env=process.env]
25
+ * @returns {boolean}
26
+ */
27
+ function shouldSkipPostinstall(env = process.env) {
28
+ return env.npm_command === 'exec';
29
+ }
30
+
19
31
  /**
20
32
  * @param {{
21
33
  * installNativeManifestFn?: typeof installNativeManifest,
@@ -23,6 +35,7 @@ const repoRoot = path.resolve(__dirname, '../../..');
23
35
  * stdout?: Pick<NodeJS.WriteStream, 'write'>,
24
36
  * stderr?: Pick<NodeJS.WriteStream, 'write'>,
25
37
  * exit?: (code?: number) => void,
38
+ * env?: NodeJS.ProcessEnv,
26
39
  * }} [deps]
27
40
  * @returns {Promise<void>}
28
41
  */
@@ -32,7 +45,12 @@ export async function runPostinstall({
32
45
  stdout = process.stdout,
33
46
  stderr = process.stderr,
34
47
  exit = (code) => process.exit(code),
48
+ env = process.env,
35
49
  } = {}) {
50
+ if (shouldSkipPostinstall(env)) {
51
+ return;
52
+ }
53
+
36
54
  try {
37
55
  await installNativeManifestFn({ repoRoot, preserveCustomExtensionId: true });
38
56
  stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
@@ -292,6 +292,29 @@ export function getDaemonPidPath(env = process.env) {
292
292
  return path.join(getBridgeDir(env), 'daemon.pid');
293
293
  }
294
294
 
295
+ /**
296
+ * Persistent daemon log. The daemon is normally spawned detached with its
297
+ * stdio redirected here so startup failures stay diagnosable after the
298
+ * process is gone.
299
+ *
300
+ * @param {NodeJS.ProcessEnv} [env=process.env]
301
+ * @returns {string}
302
+ */
303
+ export function getDaemonLogPath(env = process.env) {
304
+ return path.join(getBridgeDir(env), 'daemon.log');
305
+ }
306
+
307
+ /**
308
+ * Rolling history of daemon start timestamps, used to detect a daemon that
309
+ * keeps restarting within a short window (crash loop).
310
+ *
311
+ * @param {NodeJS.ProcessEnv} [env=process.env]
312
+ * @returns {string}
313
+ */
314
+ export function getDaemonStartHistoryPath(env = process.env) {
315
+ return path.join(getBridgeDir(env), 'daemon-starts.json');
316
+ }
317
+
295
318
  /**
296
319
  * @returns {string}
297
320
  */
@@ -12,7 +12,9 @@ import {
12
12
  createSocketBridgeTransport,
13
13
  formatBridgeTransport,
14
14
  getBridgeTransport,
15
+ getDaemonLogPath,
15
16
  getDaemonPidPath,
17
+ getDaemonStartHistoryPath,
16
18
  } from './config.js';
17
19
 
18
20
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
@@ -22,6 +24,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
24
  const daemonEntryPath = path.resolve(__dirname, '../bin/bridge-daemon.js');
23
25
  const DEFAULT_DAEMON_RESTART_TIMEOUT_MS = 5_000;
24
26
  const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
27
+ const DAEMON_LOG_MAX_BYTES = 1024 * 1024;
28
+ const DAEMON_START_HISTORY_MAX_ENTRIES = 20;
29
+
30
+ /**
31
+ * A daemon that starts this many times within the window is considered to be
32
+ * crash-looping rather than restarting normally.
33
+ */
34
+ export const DAEMON_RESTART_LOOP_THRESHOLD = 3;
35
+ export const DAEMON_RESTART_LOOP_WINDOW_MS = 60_000;
25
36
 
26
37
  /**
27
38
  * @typedef {{
@@ -56,18 +67,114 @@ const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
56
67
  * }} RestartBridgeDaemonOptions
57
68
  */
58
69
 
70
+ /**
71
+ * Open the persistent daemon log for appending, rotating it once when it grows
72
+ * past the size cap. Returns null when the log cannot be opened (for example a
73
+ * root-owned bridge dir after a sudo install) so callers can fall back to
74
+ * discarding output instead of failing the spawn.
75
+ *
76
+ * @param {string} [logPath=getDaemonLogPath()]
77
+ * @returns {number | null}
78
+ */
79
+ export function openDaemonLogFd(logPath = getDaemonLogPath()) {
80
+ try {
81
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
82
+ try {
83
+ if (fs.statSync(logPath).size > DAEMON_LOG_MAX_BYTES) {
84
+ fs.rmSync(`${logPath}.1`, { force: true });
85
+ fs.renameSync(logPath, `${logPath}.1`);
86
+ }
87
+ } catch {
88
+ // Rotation is best-effort: on Windows the rename fails while another
89
+ // daemon holds the log open. Keep appending to the current file.
90
+ }
91
+ return fs.openSync(logPath, 'a');
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
59
97
  /**
60
98
  * @returns {import('node:child_process').ChildProcess}
61
99
  */
62
100
  export function spawnBridgeDaemonProcess() {
101
+ const logFd = openDaemonLogFd();
63
102
  const child = spawn(process.execPath, [daemonEntryPath], {
64
103
  detached: true,
65
- stdio: 'ignore',
104
+ stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
66
105
  });
67
106
  child.unref();
107
+ if (logFd !== null) {
108
+ fs.closeSync(logFd);
109
+ }
68
110
  return child;
69
111
  }
70
112
 
113
+ /**
114
+ * Read the rolling daemon start history (epoch ms timestamps, oldest first).
115
+ * Missing or malformed files read as an empty history.
116
+ *
117
+ * @param {string} [historyPath=getDaemonStartHistoryPath()]
118
+ * @returns {Promise<number[]>}
119
+ */
120
+ export async function readDaemonStartHistory(historyPath = getDaemonStartHistoryPath()) {
121
+ try {
122
+ const parsed = JSON.parse(await fs.promises.readFile(historyPath, 'utf8'));
123
+ if (!Array.isArray(parsed)) {
124
+ return [];
125
+ }
126
+ return parsed.filter(
127
+ (value) => typeof value === 'number' && Number.isFinite(value) && value > 0
128
+ );
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Append a daemon start timestamp to the rolling history. Best-effort: any
136
+ * filesystem failure is swallowed so an unwritable bridge dir never prevents
137
+ * the daemon from starting.
138
+ *
139
+ * @param {{ at?: number, historyPath?: string }} [options={}]
140
+ * @returns {Promise<number[]>} the updated history (empty when persisting failed)
141
+ */
142
+ export async function recordDaemonStart(options = {}) {
143
+ const { at = Date.now(), historyPath = getDaemonStartHistoryPath() } = options;
144
+ try {
145
+ const history = [...(await readDaemonStartHistory(historyPath)), at].slice(
146
+ -DAEMON_START_HISTORY_MAX_ENTRIES
147
+ );
148
+ await fs.promises.mkdir(path.dirname(historyPath), { recursive: true });
149
+ await fs.promises.writeFile(historyPath, `${JSON.stringify(history)}\n`, 'utf8');
150
+ return history;
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Summarize the daemon start history over the recent window so callers can
158
+ * tell a crash-looping daemon apart from a normal restart.
159
+ *
160
+ * @param {number[]} history
161
+ * @param {{ now?: number, windowMs?: number, threshold?: number }} [options={}]
162
+ * @returns {{ startsInWindow: number, windowMs: number, restartLoop: boolean }}
163
+ */
164
+ export function summarizeDaemonRestarts(history, options = {}) {
165
+ const {
166
+ now = Date.now(),
167
+ windowMs = DAEMON_RESTART_LOOP_WINDOW_MS,
168
+ threshold = DAEMON_RESTART_LOOP_THRESHOLD,
169
+ } = options;
170
+ const startsInWindow = history.filter((at) => at <= now && now - at <= windowMs).length;
171
+ return {
172
+ startsInWindow,
173
+ windowMs,
174
+ restartLoop: startsInWindow >= threshold,
175
+ };
176
+ }
177
+
71
178
  /**
72
179
  * @param {number} [pid=process.pid]
73
180
  * @param {string} [pidPath=getDaemonPidPath()]
@@ -141,7 +248,8 @@ export async function stopBridgeDaemon(options = {}) {
141
248
  const resolvedSocketPath =
142
249
  resolvedTransport.type === 'socket' ? resolvedTransport.socketPath : '';
143
250
 
144
- let previousPid = await readPidFn(pidPath);
251
+ const pidFromFile = await readPidFn(pidPath);
252
+ let previousPid = pidFromFile;
145
253
  let previouslyRunning = previousPid !== null;
146
254
 
147
255
  if (previousPid === null && (await safePingDaemon(resolvedTransport, pingDaemonFn))) {
@@ -158,7 +266,7 @@ export async function stopBridgeDaemon(options = {}) {
158
266
  }
159
267
  }
160
268
 
161
- const stopped = await waitForDaemonReachability({
269
+ let stopped = await waitForDaemonReachability({
162
270
  transport: resolvedTransport,
163
271
  reachable: false,
164
272
  timeoutMs,
@@ -166,12 +274,38 @@ export async function stopBridgeDaemon(options = {}) {
166
274
  pingDaemonFn,
167
275
  sleepFn,
168
276
  });
277
+ if (!stopped) {
278
+ // The pid file can be stale (e.g. a root-owned leftover from a sudo
279
+ // install that the daemon could not overwrite) while a daemon with a
280
+ // different pid owns the socket. Target the socket owner before giving up.
281
+ const socketOwnerPid = await findPidByTransportFn(resolvedTransport);
282
+ if (socketOwnerPid !== null && socketOwnerPid !== previousPid) {
283
+ try {
284
+ killFn(socketOwnerPid, 'SIGTERM');
285
+ } catch (error) {
286
+ if (!isMissingProcessError(error)) {
287
+ throw error;
288
+ }
289
+ }
290
+ stopped = await waitForDaemonReachability({
291
+ transport: resolvedTransport,
292
+ reachable: false,
293
+ timeoutMs,
294
+ pollIntervalMs,
295
+ pingDaemonFn,
296
+ sleepFn,
297
+ });
298
+ if (stopped) {
299
+ previousPid = socketOwnerPid;
300
+ }
301
+ }
302
+ }
169
303
  if (!stopped) {
170
304
  throw new Error(`Timed out waiting for Browser Bridge daemon (pid ${previousPid}) to stop.`);
171
305
  }
172
306
  }
173
307
 
174
- await clearDaemonPidFile({ pid: previousPid, pidPath, rmFn });
308
+ await clearDaemonPidFile({ pid: pidFromFile ?? previousPid, pidPath, rmFn });
175
309
 
176
310
  const removedStaleSocket = await removeStaleSocket(resolvedTransport, rmFn, pingDaemonFn);
177
311
  return {
@@ -24,9 +24,10 @@ import {
24
24
  DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
25
25
  DEFAULT_LOG_TAIL_LIMIT,
26
26
  ERROR_CODES,
27
+ getProtocolVersion,
28
+ getSupportedProtocolVersions,
27
29
  parseJsonLines,
28
- PROTOCOL_VERSION,
29
- SUPPORTED_VERSIONS,
30
+ setProtocolPackageVersion,
30
31
  validateBridgeRequest,
31
32
  } from '../../protocol/src/index.js';
32
33
  import {
@@ -45,6 +46,7 @@ import { normalizeDaemonLogger } from './daemon-logger.js';
45
46
  import { writeJsonLine } from './framing.js';
46
47
 
47
48
  const DAEMON_VERSION = loadDaemonVersion();
49
+ setProtocolPackageVersion(DAEMON_VERSION);
48
50
 
49
51
  /** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
50
52
  /** @typedef {import('../../protocol/src/types.js').SetupInstallParams} SetupInstallParams */
@@ -89,14 +91,15 @@ function compareProtocolVersions(left, right) {
89
91
  * @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
90
92
  */
91
93
  function getVersionNegotiationPayload(requestedVersion) {
92
- const latestSupported = SUPPORTED_VERSIONS[0];
93
- if (!requestedVersion || !latestSupported || SUPPORTED_VERSIONS.includes(requestedVersion)) {
94
- return { supported_versions: SUPPORTED_VERSIONS };
94
+ const supportedVersions = getSupportedProtocolVersions();
95
+ const latestSupported = supportedVersions[0];
96
+ if (!requestedVersion || !latestSupported || supportedVersions.includes(requestedVersion)) {
97
+ return { supported_versions: supportedVersions };
95
98
  }
96
99
 
97
100
  const localIsNewer = compareProtocolVersions(latestSupported, requestedVersion) > 0;
98
101
  return {
99
- supported_versions: SUPPORTED_VERSIONS,
102
+ supported_versions: supportedVersions,
100
103
  ...(localIsNewer ? { deprecated_since: latestSupported } : {}),
101
104
  migration_hint: localIsNewer
102
105
  ? `Browser Bridge daemon is newer than the client protocol ${requestedVersion}. Restart or update the Browser Bridge CLI/npm package to ${latestSupported} or later.`
@@ -640,7 +643,7 @@ export class BridgeDaemon {
640
643
  transport: formatBridgeTransport(this.transport),
641
644
  proxy: this.getProxyStatusPayload(),
642
645
  connectedExtensions: [],
643
- daemon_supported_versions: SUPPORTED_VERSIONS,
646
+ daemon_supported_versions: getSupportedProtocolVersions(),
644
647
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
645
648
  });
646
649
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -948,7 +951,7 @@ export class BridgeDaemon {
948
951
  ...getVersionNegotiationPayload(pending.protocolVersion),
949
952
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
950
953
  daemonVersion: DAEMON_VERSION,
951
- daemon_supported_versions: SUPPORTED_VERSIONS,
954
+ daemon_supported_versions: getSupportedProtocolVersions(),
952
955
  proxy: this.getProxyStatusPayload(),
953
956
  },
954
957
  {
@@ -1161,7 +1164,7 @@ export async function pingExistingDaemon(transport) {
1161
1164
  method: 'health.ping',
1162
1165
  tab_id: null,
1163
1166
  params: {},
1164
- meta: { protocol_version: PROTOCOL_VERSION, token_budget: null },
1167
+ meta: { protocol_version: getProtocolVersion(), token_budget: null },
1165
1168
  },
1166
1169
  })}\n`
1167
1170
  );
@@ -125,7 +125,7 @@ export async function runNativeHost({
125
125
  socket = await connectWithBootstrap(resolvedTransport);
126
126
  } catch (error) {
127
127
  await writeNativeMessageQueued({
128
- type: 'agent.response',
128
+ type: 'host.bridge_response',
129
129
  response: createFailure(
130
130
  'native_bootstrap',
131
131
  ERROR_CODES.NATIVE_HOST_UNAVAILABLE,
@@ -82,10 +82,59 @@ import { BRIDGE_METHODS, METHOD_SET, createBridgeMethodGroups } from './registry
82
82
  /** @typedef {import('./types.js').WaitForLoadStateParams} WaitForLoadStateParams */
83
83
  /** @typedef {import('./types.js').WaitForParams} WaitForParams */
84
84
 
85
- export const PROTOCOL_VERSION = '1.0';
85
+ /** @type {string | null} */
86
+ let protocolPackageVersion = null;
87
+
88
+ /**
89
+ * @param {string | null | undefined} packageVersion
90
+ * @returns {string}
91
+ */
92
+ export function deriveProtocolVersion(packageVersion) {
93
+ const match = typeof packageVersion === 'string' ? /^(\d+)\.(\d+)/.exec(packageVersion) : null;
94
+ return match ? `${match[1]}.${match[2]}` : '0.0';
95
+ }
96
+
97
+ /**
98
+ * @returns {string | null}
99
+ */
100
+ function getRuntimePackageVersion() {
101
+ const chromeRuntime = /** @type {{ runtime?: { getManifest?: () => { version?: unknown } } }} */ (
102
+ globalThis.chrome ?? {}
103
+ ).runtime;
104
+ const manifestVersion = chromeRuntime?.getManifest?.().version;
105
+ if (typeof manifestVersion === 'string') {
106
+ return manifestVersion;
107
+ }
108
+
109
+ const npmPackageVersion = globalThis.process?.env?.npm_package_version;
110
+ return typeof npmPackageVersion === 'string' ? npmPackageVersion : null;
111
+ }
112
+
113
+ /**
114
+ * @param {string | null | undefined} packageVersion
115
+ * @returns {void}
116
+ */
117
+ export function setProtocolPackageVersion(packageVersion) {
118
+ protocolPackageVersion = typeof packageVersion === 'string' ? packageVersion : null;
119
+ }
120
+
121
+ /**
122
+ * @returns {string}
123
+ */
124
+ export function getProtocolVersion() {
125
+ return deriveProtocolVersion(protocolPackageVersion ?? getRuntimePackageVersion());
126
+ }
127
+
128
+ /** Snapshot retained for older callers; new code should call getProtocolVersion(). */
129
+ export const PROTOCOL_VERSION = getProtocolVersion();
86
130
 
87
131
  /** Versions this build can speak. Newest first. */
88
- export const SUPPORTED_VERSIONS = Object.freeze(['1.0']);
132
+ export function getSupportedProtocolVersions() {
133
+ return Object.freeze([getProtocolVersion()]);
134
+ }
135
+
136
+ /** Snapshot retained for older callers; new code should call getSupportedProtocolVersions(). */
137
+ export const SUPPORTED_VERSIONS = getSupportedProtocolVersions();
89
138
 
90
139
  /**
91
140
  * Clamp a numeric value between min and max, falling back to a default.
@@ -122,7 +171,7 @@ export function createRequest({ id, method, tabId = null, params = {}, meta = {}
122
171
  tab_id: tabId,
123
172
  params,
124
173
  meta: {
125
- protocol_version: PROTOCOL_VERSION,
174
+ protocol_version: getProtocolVersion(),
126
175
  ...meta,
127
176
  },
128
177
  });
@@ -141,7 +190,7 @@ export function createSuccess(id, result, meta = {}) {
141
190
  result,
142
191
  error: null,
143
192
  meta: {
144
- protocol_version: PROTOCOL_VERSION,
193
+ protocol_version: getProtocolVersion(),
145
194
  ...meta,
146
195
  },
147
196
  };
@@ -168,7 +217,7 @@ export function createFailure(id, code, message, details = null, meta = {}) {
168
217
  ...(recovery && { recovery }),
169
218
  },
170
219
  meta: {
171
- protocol_version: PROTOCOL_VERSION,
220
+ protocol_version: getProtocolVersion(),
172
221
  ...meta,
173
222
  },
174
223
  };
@@ -238,7 +287,7 @@ export function validateBridgeRequest(request) {
238
287
  meta: {
239
288
  ...meta,
240
289
  protocol_version:
241
- typeof meta.protocol_version === 'string' ? meta.protocol_version : PROTOCOL_VERSION,
290
+ typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
242
291
  token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
243
292
  source: meta.source === 'cli' || meta.source === 'mcp' ? meta.source : undefined,
244
293
  },
@@ -842,7 +891,7 @@ export function createRuntimeContext() {
842
891
  const methodGroups = createBridgeMethodGroups();
843
892
 
844
893
  return {
845
- v: PROTOCOL_VERSION,
894
+ v: getProtocolVersion(),
846
895
  budgets: {
847
896
  quick: {
848
897
  n: BUDGET_PRESETS.quick.maxNodes,
@@ -1,6 +1,11 @@
1
1
  // @ts-check
2
2
 
3
- import { estimateSerializedPayloadCost, getCostClass, serializeJsonPayload } from './index.js';
3
+ import {
4
+ estimateSerializedPayloadCost,
5
+ getCostClass,
6
+ getProtocolVersion,
7
+ serializeJsonPayload,
8
+ } from './index.js';
4
9
 
5
10
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
6
11
  /** @typedef {import('./types.js').BridgeMethod} SummaryMethod */
@@ -692,7 +697,7 @@ export function summarizeBatchErrorItem({ method, tabId, error, durationMs }) {
692
697
  message,
693
698
  details: null,
694
699
  },
695
- meta: { protocol_version: '1.0' },
700
+ meta: { protocol_version: getProtocolVersion() },
696
701
  });
697
702
  const summary = annotateBridgeSummary(summarizeBridgeResponse(response, method), response);
698
703
  return {
@@ -348,7 +348,7 @@ bbx call screenshot.capture_full_page '{}'
348
348
  "tab_id": 123,
349
349
  "method": "dom.query",
350
350
  "params": {},
351
- "meta": { "protocol_version": "1.0", "token_budget": 1200 }
351
+ "meta": { "protocol_version": "<package major.minor>", "token_budget": 1200 }
352
352
  }
353
353
  ```
354
354