@browserbridge/bbx 1.7.6 → 1.7.7

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
@@ -114,7 +114,7 @@ Browser Bridge is optimized for the opposite starting point: **inspect the state
114
114
 
115
115
  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.
116
116
 
117
- 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.
117
+ MCP mode is self-contained: the server exposes tools and startup instructions, so a separate CLI skill is not required for MCP guidance.
118
118
 
119
119
  ## How it works
120
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -1,10 +1,5 @@
1
1
  // @ts-check
2
2
 
3
- import * as z from 'zod/v4';
4
-
5
- /** @typedef {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} McpServer */
6
- /** @typedef {import('@modelcontextprotocol/sdk/types.js').GetPromptResult} GetPromptResult */
7
-
8
3
  export const MCP_SERVER_INSTRUCTIONS = [
9
4
  "Browser Bridge MCP inspects and interacts with the user's real Chrome tab through typed MCP tools.",
10
5
  'In permission-ask hosts, use browser_call as the default Browser Bridge MCP tool so the user can approve one BBX tool instead of separate browser_status, browser_page, browser_dom, browser_input, and other tools.',
@@ -15,231 +10,8 @@ export const MCP_SERVER_INSTRUCTIONS = [
15
10
  'Escalate to screenshot.capture_element, screenshot.capture_region, dom.get_accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
16
11
  'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
17
12
  'Only use the specialized Browser Bridge MCP tools directly when the host has already allowed them or the user explicitly wants typed tool calls.',
13
+ '',
14
+ 'Page investigation: read page state, scoped DOM, and relevant page text first, batching independent reads. Find targets by text or role when selectors are unknown. Add styles, console, or network reads only when they directly answer the objective, and use screenshots or evaluation only when structured evidence is insufficient.',
15
+ 'Layout debugging: locate the target, read only relevant computed properties and its box model, and inspect matched rules only when the cascade is unclear. Prototype the smallest fix with a reversible style patch, verify the result, check for new console errors, then rollback before editing source unless the user asks to keep the patch.',
16
+ 'Flow verification: read initial page state, locate controls semantically, reuse elementRef values, interact through input tools, and wait for navigation or UI state changes. Verify the final DOM or page text plus console and network state when relevant. Do not create a new tab unless requested or required by the flow.',
18
17
  ].join('\n');
19
-
20
- export const MCP_GUIDANCE_PROMPT_NAMES = Object.freeze([
21
- 'browser_bridge_guide',
22
- 'browser_bridge_investigate',
23
- 'browser_bridge_debug_layout',
24
- 'browser_bridge_verify_flow',
25
- ]);
26
-
27
- /**
28
- * Register Browser Bridge MCP prompt templates. These are the MCP-mode equivalent
29
- * of a lightweight skill: discoverable by clients without requiring filesystem
30
- * skill installation or shell access.
31
- *
32
- * @param {McpServer} server
33
- * @returns {void}
34
- */
35
- export function registerBridgeMcpGuidance(server) {
36
- server.registerPrompt(
37
- 'browser_bridge_guide',
38
- {
39
- title: 'Use Browser Bridge MCP',
40
- description:
41
- 'General Browser Bridge MCP workflow guidance. Prefer this over CLI skill setup.',
42
- },
43
- createGuidePrompt
44
- );
45
-
46
- server.registerPrompt(
47
- 'browser_bridge_investigate',
48
- {
49
- title: 'Investigate Current Page',
50
- description:
51
- 'Inspect the current page with structured reads before screenshots or evaluation.',
52
- argsSchema: {
53
- objective: z.string().optional().describe('What to find, verify, or explain'),
54
- selector: z
55
- .string()
56
- .optional()
57
- .describe('Optional CSS selector to scope the first DOM read'),
58
- scope: z.enum(['quick', 'normal', 'deep']).optional().describe('Investigation depth'),
59
- },
60
- },
61
- createInvestigatePrompt
62
- );
63
-
64
- server.registerPrompt(
65
- 'browser_bridge_debug_layout',
66
- {
67
- title: 'Debug Layout Or Styling',
68
- description: 'Diagnose a visual, spacing, sizing, visibility, or CSS issue in the live tab.',
69
- argsSchema: {
70
- target: z.string().optional().describe('Element, component, text, or selector to inspect'),
71
- symptom: z.string().optional().describe('Observed layout or styling problem'),
72
- },
73
- },
74
- createDebugLayoutPrompt
75
- );
76
-
77
- server.registerPrompt(
78
- 'browser_bridge_verify_flow',
79
- {
80
- title: 'Verify User Flow',
81
- description:
82
- 'Drive a user flow through MCP input tools and verify page, console, and network state.',
83
- argsSchema: {
84
- flow: z.string().optional().describe('User flow to exercise'),
85
- successCriteria: z.string().optional().describe('Expected successful outcome'),
86
- },
87
- },
88
- createVerifyFlowPrompt
89
- );
90
- }
91
-
92
- /**
93
- * @returns {GetPromptResult}
94
- */
95
- function createGuidePrompt() {
96
- return createUserPrompt(
97
- 'Browser Bridge MCP workflow guide.',
98
- [
99
- 'Use Browser Bridge MCP for this browser task.',
100
- '',
101
- 'Rules:',
102
- '1. Prefer MCP over `bbx`; do not shell out unless setup, doctor, logs, or raw CLI debugging is explicitly needed.',
103
- '2. In permission-ask hosts, use `browser_call` as the default tool so the user can approve one BBX MCP tool instead of separate tools for status, page, DOM, input, and patches.',
104
- '3. Start with `browser_call` method `health.ping`. If access is disabled, call `browser_call` method `access.request` once, ask the user to click Enable, then retry once.',
105
- '4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, and `styles.get_computed`. Use `browser_batch` for parallel reads.',
106
- '5. `browser_call` accepts `budgetPreset`; explicit method limits override preset values. Keep limits tight and widen only when results are truncated.',
107
- '6. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
108
- '7. Escalate to screenshots, accessibility tree, `page.evaluate`, viewport resize, or CDP only when structured reads cannot answer.',
109
- '8. Use `patch.apply_styles` or `patch.apply_dom` for temporary experiments and rollback before finishing unless the user asks to keep patches.',
110
- '9. Only use specialized Browser Bridge MCP tools directly when the host has already allowed them or the user explicitly wants typed tool calls.',
111
- '',
112
- 'Return concise findings with evidence. Edit source code only after the live page behavior is understood.',
113
- ].join('\n')
114
- );
115
- }
116
-
117
- /**
118
- * @param {{ objective?: string, selector?: string, scope?: 'quick' | 'normal' | 'deep' }} args
119
- * @returns {GetPromptResult}
120
- */
121
- function createInvestigatePrompt(args) {
122
- const objective = normalizeTextArg(
123
- args.objective,
124
- 'inspect the current page and report findings'
125
- );
126
- const selector = normalizeTextArg(
127
- args.selector,
128
- 'none; start with main, body, or semantic search'
129
- );
130
- const scope = normalizeTextArg(args.scope, 'normal');
131
-
132
- return createUserPrompt(
133
- 'Browser Bridge MCP page investigation workflow.',
134
- [
135
- 'Investigate the current page with Browser Bridge MCP.',
136
- '',
137
- `Objective: ${objective}`,
138
- `Scope: ${scope}`,
139
- `Initial selector: ${selector}`,
140
- '',
141
- 'Workflow:',
142
- '1. Call `browser_status` to confirm daemon, extension, and access readiness.',
143
- '2. If access is disabled, call `browser_access` once, ask the user to click Enable, then retry once.',
144
- '3. Use `browser_batch` for independent structured reads, usually `page.get_state`, a scoped `dom.query`, and `page.get_text` when page copy matters.',
145
- '4. Use `browser_dom` `find_text` or `find_role` when the target is known by label but not selector.',
146
- '5. Add `browser_styles_layout`, `browser_page` console, or `browser_page` network only when they directly answer the objective.',
147
- '6. Escalate to screenshots, accessibility tree, or evaluate only when structured reads are insufficient.',
148
- '',
149
- 'Return concise findings, relevant evidence, and the next source-code action if a fix is needed.',
150
- ].join('\n')
151
- );
152
- }
153
-
154
- /**
155
- * @param {{ target?: string, symptom?: string }} args
156
- * @returns {GetPromptResult}
157
- */
158
- function createDebugLayoutPrompt(args) {
159
- const target = normalizeTextArg(args.target, 'the affected element or component');
160
- const symptom = normalizeTextArg(args.symptom, 'the observed layout or styling problem');
161
-
162
- return createUserPrompt(
163
- 'Browser Bridge MCP layout debugging workflow.',
164
- [
165
- 'Debug a layout or styling issue in the live tab with Browser Bridge MCP.',
166
- '',
167
- `Target: ${target}`,
168
- `Symptom: ${symptom}`,
169
- '',
170
- 'Workflow:',
171
- '1. Call `browser_status` first and resolve access if needed.',
172
- '2. Locate the target with `browser_dom` `query`, `find_text`, or `find_role` using a quick budget.',
173
- '3. Read only relevant computed styles with `browser_styles_layout` action `computed` and specific `properties`.',
174
- '4. Read dimensions with `browser_styles_layout` action `box_model`; use matched rules only when the cascade is unclear.',
175
- '5. Prototype the smallest visual fix with `browser_patch` action `apply_styles` and `verify: true` when useful.',
176
- '6. Check `browser_page` console for new errors after interaction or patching.',
177
- '7. Roll back temporary patches unless the user explicitly wants them kept, then edit source with the confirmed fix.',
178
- '',
179
- 'Avoid screenshots until DOM, computed styles, and box model evidence are insufficient.',
180
- ].join('\n')
181
- );
182
- }
183
-
184
- /**
185
- * @param {{ flow?: string, successCriteria?: string }} args
186
- * @returns {GetPromptResult}
187
- */
188
- function createVerifyFlowPrompt(args) {
189
- const flow = normalizeTextArg(args.flow, 'the requested user flow');
190
- const successCriteria = normalizeTextArg(
191
- args.successCriteria,
192
- 'visible success state, no console errors, and expected network behavior'
193
- );
194
-
195
- return createUserPrompt(
196
- 'Browser Bridge MCP user-flow verification workflow.',
197
- [
198
- 'Verify a user flow in the current real browser tab with Browser Bridge MCP.',
199
- '',
200
- `Flow: ${flow}`,
201
- `Success criteria: ${successCriteria}`,
202
- '',
203
- 'Workflow:',
204
- '1. Call `browser_status` and resolve access if needed.',
205
- '2. Read `browser_page` state so you know the current URL and title before interacting.',
206
- '3. Locate controls semantically with `browser_dom` `find_role` or `find_text`; reuse returned `elementRef` values.',
207
- '4. Interact with `browser_input` actions such as `click`, `type`, `set_checked`, `select_option`, and `press_key`.',
208
- '5. After navigation or UI changes, wait with `browser_dom` action `wait` or `browser_page` action `wait_for_load`.',
209
- '6. Verify final page text/DOM plus `browser_page` console and network if the flow depends on API calls.',
210
- '7. Do not create new tabs unless the user requested a fresh page or the flow requires one.',
211
- '',
212
- 'Report the verified result, evidence, and any blocking failures.',
213
- ].join('\n')
214
- );
215
- }
216
-
217
- /**
218
- * @param {string} description
219
- * @param {string} text
220
- * @returns {GetPromptResult}
221
- */
222
- function createUserPrompt(description, text) {
223
- return {
224
- description,
225
- messages: [
226
- {
227
- role: 'user',
228
- content: {
229
- type: 'text',
230
- text,
231
- },
232
- },
233
- ],
234
- };
235
- }
236
-
237
- /**
238
- * @param {string | undefined} value
239
- * @param {string} fallback
240
- * @returns {string}
241
- */
242
- function normalizeTextArg(value, fallback) {
243
- const text = typeof value === 'string' ? value.trim() : '';
244
- return text || fallback;
245
- }
@@ -44,7 +44,7 @@ import {
44
44
  getMethodsByMaxComplexity,
45
45
  } from '../../protocol/src/index.js';
46
46
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
47
- import { MCP_SERVER_INSTRUCTIONS, registerBridgeMcpGuidance } from './guidance.js';
47
+ import { MCP_SERVER_INSTRUCTIONS } from './guidance.js';
48
48
 
49
49
  export const BUDGET_PRESET_DESCRIPTION = `Budget preset: "quick", "normal", or "deep" (defaults: query ${BUDGET_PRESETS.normal.maxNodes} nodes / depth ${BUDGET_PRESETS.normal.maxDepth} / text ${BUDGET_PRESETS.normal.textBudget}). Numeric fields override the preset when both are provided.`;
50
50
  export const TAB_ID_DESCRIPTION =
@@ -109,8 +109,6 @@ export function createBridgeMcpServer() {
109
109
  }
110
110
  );
111
111
 
112
- registerBridgeMcpGuidance(server);
113
-
114
112
  server.registerTool(
115
113
  'browser_status',
116
114
  {
@@ -44,7 +44,8 @@ try {
44
44
  await writeDaemonPidFile(process.pid);
45
45
  } catch (error) {
46
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.
47
+ // a daemon that is already listening; `bbx` falls back to endpoint-owner
48
+ // discovery (lsof/ss on the socket, verified TCP listener lookup) for the pid.
48
49
  process.stderr.write(
49
50
  `Could not write daemon pid file: ${error instanceof Error ? error.message : String(error)}\n`
50
51
  );
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { randomBytes } from 'node:crypto';
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
@@ -34,6 +34,27 @@ export function normalizeBridgeAuthToken(value) {
34
34
  return TOKEN_PATTERN.test(token) || UUID_TOKEN_PATTERN.test(token) ? token : null;
35
35
  }
36
36
 
37
+ /**
38
+ * Compare a candidate auth token against the expected token in constant time.
39
+ * Both sides are hashed before comparison so neither token length nor a
40
+ * matching prefix is observable through timing, which matters when the daemon
41
+ * listens on TCP (proxy mode exposes it beyond localhost).
42
+ *
43
+ * @param {unknown} candidate
44
+ * @param {string | null | undefined} expected
45
+ * @returns {boolean}
46
+ */
47
+ export function bridgeAuthTokensEqual(candidate, expected) {
48
+ const normalizedCandidate = normalizeBridgeAuthToken(candidate);
49
+ if (!normalizedCandidate || typeof expected !== 'string' || !expected) {
50
+ return false;
51
+ }
52
+ return timingSafeEqual(
53
+ createHash('sha256').update(normalizedCandidate).digest(),
54
+ createHash('sha256').update(expected).digest()
55
+ );
56
+ }
57
+
37
58
  /**
38
59
  * @param {{ env?: NodeJS.ProcessEnv, readFile?: typeof fs.promises.readFile }} [options={}]
39
60
  * @returns {Promise<string | null>}
@@ -120,30 +120,65 @@ function normalizePort(value) {
120
120
  }
121
121
 
122
122
  /**
123
+ * @param {string} configPath
124
+ * @param {string} reason
125
+ * @returns {void}
126
+ */
127
+ function warnProxyConfigIgnored(configPath, reason) {
128
+ console.error(`browser-bridge: ignoring proxy config at ${configPath}: ${reason}`);
129
+ }
130
+
131
+ /**
132
+ * Read the opt-in TCP proxy config. A missing file and `enabled: false` are
133
+ * the normal quiet cases; anything else that prevents using the config gets a
134
+ * stderr warning so a corrupt file does not silently fall back to the socket
135
+ * transport. An invalid `bindHost` rejects the whole config rather than
136
+ * defaulting to `0.0.0.0`, so a typo in a value meant to restrict binding
137
+ * cannot widen exposure to all interfaces.
138
+ *
123
139
  * @param {NodeJS.ProcessEnv} [env=process.env]
124
140
  * @returns {BridgeProxyConfig | null}
125
141
  */
126
142
  export function readProxyConfig(env = process.env) {
143
+ const configPath = getProxyConfigPath(env);
144
+ /** @type {unknown} */
145
+ let parsed;
127
146
  try {
128
- const parsed = JSON.parse(fs.readFileSync(getProxyConfigPath(env), 'utf8'));
129
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
- return null;
147
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
148
+ } catch (error) {
149
+ if (/** @type {{ code?: unknown }} */ (error)?.code !== 'ENOENT') {
150
+ warnProxyConfigIgnored(configPath, error instanceof Error ? error.message : String(error));
131
151
  }
132
- const record = /** @type {Record<string, unknown>} */ (parsed);
133
- const port = normalizePort(record.port);
134
- const bindHost = normalizeHost(record.bindHost) ?? '0.0.0.0';
135
- if (record.enabled !== true || port === null) {
152
+ return null;
153
+ }
154
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
155
+ warnProxyConfigIgnored(configPath, 'expected a JSON object');
156
+ return null;
157
+ }
158
+ const record = /** @type {Record<string, unknown>} */ (parsed);
159
+ if (record.enabled !== true) {
160
+ return null;
161
+ }
162
+ const port = normalizePort(record.port);
163
+ if (port === null) {
164
+ warnProxyConfigIgnored(configPath, 'invalid "port" value');
165
+ return null;
166
+ }
167
+ let bindHost = '0.0.0.0';
168
+ if (record.bindHost != null) {
169
+ const normalized = normalizeHost(record.bindHost);
170
+ if (!normalized) {
171
+ warnProxyConfigIgnored(configPath, 'invalid "bindHost" value');
136
172
  return null;
137
173
  }
138
- return {
139
- enabled: true,
140
- port,
141
- bindHost,
142
- ...(typeof record.token === 'string' ? { token: record.token } : {}),
143
- };
144
- } catch {
145
- return null;
174
+ bindHost = normalized;
146
175
  }
176
+ return {
177
+ enabled: true,
178
+ port,
179
+ bindHost,
180
+ ...(typeof record.token === 'string' ? { token: record.token } : {}),
181
+ };
147
182
  }
148
183
 
149
184
  /**
@@ -260,8 +260,9 @@ export async function stopBridgeDaemon(options = {}) {
260
260
  previousPid = endpointOwnerPid;
261
261
  previouslyRunning = true;
262
262
  } else if (reachable) {
263
- // TCP endpoints do not expose an owner pid. In that case daemon
264
- // reachability is the evidence supporting the recorded pid.
263
+ // When the endpoint owner is not discoverable (no verified TCP listener,
264
+ // lsof and ss both unavailable), daemon reachability is the evidence
265
+ // supporting the recorded pid.
265
266
  previousPid = pidFromFile;
266
267
  previouslyRunning = true;
267
268
  }
@@ -442,28 +443,40 @@ function resolveDaemonTransport(options) {
442
443
  return getBridgeTransport(env);
443
444
  }
444
445
 
446
+ /**
447
+ * @typedef {{
448
+ * execFileFn?: typeof execFileAsync,
449
+ * platform?: NodeJS.Platform,
450
+ * readFileFn?: typeof fs.promises.readFile,
451
+ * }} FindDaemonPidOptions
452
+ */
453
+
445
454
  /**
446
455
  * @param {BridgeTransport} transport
456
+ * @param {FindDaemonPidOptions} [options={}]
447
457
  * @returns {Promise<number | null>}
448
458
  */
449
- export async function findDaemonPidByTransport(transport) {
450
- if (transport.type !== 'socket') {
451
- return null;
459
+ export async function findDaemonPidByTransport(transport, options = {}) {
460
+ if (transport.type === 'socket') {
461
+ return findDaemonPidBySocket(transport.socketPath, options);
452
462
  }
453
- return findDaemonPidBySocket(transport.socketPath);
463
+ return findDaemonPidByTcpPort(transport.port, options);
454
464
  }
455
465
 
456
466
  /**
457
467
  * @param {string} socketPath
468
+ * @param {FindDaemonPidOptions} [options={}]
458
469
  * @returns {Promise<number | null>}
459
470
  */
460
- export async function findDaemonPidBySocket(socketPath) {
461
- if (process.platform === 'win32') {
471
+ export async function findDaemonPidBySocket(socketPath, options = {}) {
472
+ const { execFileFn = execFileAsync, platform = process.platform } = options;
473
+ if (platform === 'win32') {
474
+ // Named pipes do not expose an owner pid through a filesystem path.
462
475
  return null;
463
476
  }
464
477
 
465
478
  try {
466
- const { stdout } = await execFileAsync('lsof', ['-t', '--', socketPath]);
479
+ const { stdout } = await execFileFn('lsof', ['-t', '--', socketPath]);
467
480
  const pid = Number.parseInt(
468
481
  stdout
469
482
  .split(/\r?\n/u)
@@ -473,13 +486,208 @@ export async function findDaemonPidBySocket(socketPath) {
473
486
  );
474
487
  return Number.isInteger(pid) && pid > 0 ? pid : null;
475
488
  } catch (error) {
476
- if (isCommandNotFoundError(error) || isLsofNoResultsError(error)) {
489
+ if (isCommandNotFoundError(error)) {
490
+ // macOS always ships lsof; minimal Linux installs often do not, so fall
491
+ // back to iproute2's ss which is effectively universal there.
492
+ return platform === 'linux' ? findLinuxSocketOwnerPidWithSs(socketPath, execFileFn) : null;
493
+ }
494
+ if (isLsofNoResultsError(error)) {
477
495
  return null;
478
496
  }
479
497
  throw error;
480
498
  }
481
499
  }
482
500
 
501
+ /**
502
+ * @param {string} socketPath
503
+ * @param {typeof execFileAsync} execFileFn
504
+ * @returns {Promise<number | null>}
505
+ */
506
+ async function findLinuxSocketOwnerPidWithSs(socketPath, execFileFn) {
507
+ try {
508
+ const { stdout } = await execFileFn('ss', ['-x', '-l', '-p', 'src', socketPath]);
509
+ for (const line of stdout.split(/\r?\n/u)) {
510
+ if (!line.includes(socketPath)) {
511
+ continue;
512
+ }
513
+ const match = /pid=(\d+)/u.exec(line);
514
+ const pid = match ? Number.parseInt(match[1], 10) : Number.NaN;
515
+ if (Number.isInteger(pid) && pid > 0) {
516
+ return pid;
517
+ }
518
+ }
519
+ return null;
520
+ } catch {
521
+ // ss is a best-effort fallback; the caller degrades to the pid file.
522
+ return null;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Find the pid of the bridge daemon listening on a local TCP port (the default
528
+ * transport on Windows and the opt-in proxy transport elsewhere). Unlike a
529
+ * bridge-owned socket path, a TCP port could be held by an unrelated process,
530
+ * so a candidate pid is only returned after its command line is confirmed to
531
+ * reference the bridge daemon entrypoint. Discovery is opportunistic: any
532
+ * tooling failure yields null and the caller degrades to the pid file.
533
+ *
534
+ * @param {number} port
535
+ * @param {FindDaemonPidOptions} [options={}]
536
+ * @returns {Promise<number | null>}
537
+ */
538
+ export async function findDaemonPidByTcpPort(port, options = {}) {
539
+ const {
540
+ execFileFn = execFileAsync,
541
+ platform = process.platform,
542
+ readFileFn = fs.promises.readFile,
543
+ } = options;
544
+
545
+ if (platform === 'win32') {
546
+ return findWindowsTcpDaemonPid(port, execFileFn);
547
+ }
548
+
549
+ const candidatePids = await findPosixTcpListenerPids(port, execFileFn, platform);
550
+ for (const pid of candidatePids) {
551
+ const command = await getPosixProcessCommand(pid, execFileFn, platform, readFileFn);
552
+ if (command.includes(DAEMON_ENTRY_BASENAME)) {
553
+ return pid;
554
+ }
555
+ }
556
+ return null;
557
+ }
558
+
559
+ const DAEMON_ENTRY_BASENAME = 'bridge-daemon';
560
+
561
+ /**
562
+ * @returns {string}
563
+ */
564
+ function getWindowsPowerShellExe() {
565
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR;
566
+ return systemRoot
567
+ ? path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
568
+ : 'powershell.exe';
569
+ }
570
+
571
+ /**
572
+ * Resolve and verify the listening daemon pid in a single PowerShell call.
573
+ * Get-NetTCPConnection is locale-independent, unlike netstat's state column.
574
+ * `$listenerPid` deliberately avoids PowerShell's reserved `$PID` variable.
575
+ *
576
+ * @param {number} port
577
+ * @param {typeof execFileAsync} execFileFn
578
+ * @returns {Promise<number | null>}
579
+ */
580
+ async function findWindowsTcpDaemonPid(port, execFileFn) {
581
+ const script = [
582
+ `$listenerPids = @(Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { $_.OwningProcess } | Sort-Object -Unique)`,
583
+ 'foreach ($listenerPid in $listenerPids) {',
584
+ ` $commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$listenerPid" -ErrorAction SilentlyContinue).CommandLine`,
585
+ ` if ($commandLine -like '*${DAEMON_ENTRY_BASENAME}*') { Write-Output $listenerPid; break }`,
586
+ '}',
587
+ ].join(' ');
588
+
589
+ try {
590
+ const { stdout } = await execFileFn(getWindowsPowerShellExe(), [
591
+ '-NoLogo',
592
+ '-NoProfile',
593
+ '-NonInteractive',
594
+ '-Command',
595
+ script,
596
+ ]);
597
+ const pid = Number.parseInt(
598
+ stdout
599
+ .split(/\r?\n/u)
600
+ .map((line) => line.trim())
601
+ .find(Boolean) ?? '',
602
+ 10
603
+ );
604
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * @param {number} port
612
+ * @param {typeof execFileAsync} execFileFn
613
+ * @param {NodeJS.Platform} platform
614
+ * @returns {Promise<number[]>}
615
+ */
616
+ async function findPosixTcpListenerPids(port, execFileFn, platform) {
617
+ try {
618
+ const { stdout } = await execFileFn('lsof', ['-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN']);
619
+ return parsePidLines(stdout);
620
+ } catch (error) {
621
+ if (isCommandNotFoundError(error) && platform === 'linux') {
622
+ return findLinuxTcpListenerPidsWithSs(port, execFileFn);
623
+ }
624
+ return [];
625
+ }
626
+ }
627
+
628
+ /**
629
+ * @param {number} port
630
+ * @param {typeof execFileAsync} execFileFn
631
+ * @returns {Promise<number[]>}
632
+ */
633
+ async function findLinuxTcpListenerPidsWithSs(port, execFileFn) {
634
+ try {
635
+ const { stdout } = await execFileFn('ss', ['-t', '-l', '-n', '-p', 'sport', '=', `:${port}`]);
636
+ /** @type {number[]} */
637
+ const pids = [];
638
+ for (const match of stdout.matchAll(/pid=(\d+)/gu)) {
639
+ const pid = Number.parseInt(match[1], 10);
640
+ if (Number.isInteger(pid) && pid > 0 && !pids.includes(pid)) {
641
+ pids.push(pid);
642
+ }
643
+ }
644
+ return pids;
645
+ } catch {
646
+ return [];
647
+ }
648
+ }
649
+
650
+ /**
651
+ * @param {number} pid
652
+ * @param {typeof execFileAsync} execFileFn
653
+ * @param {NodeJS.Platform} platform
654
+ * @param {typeof fs.promises.readFile} readFileFn
655
+ * @returns {Promise<string>}
656
+ */
657
+ async function getPosixProcessCommand(pid, execFileFn, platform, readFileFn) {
658
+ if (platform === 'linux') {
659
+ // /proc avoids depending on a full procps ps (busybox lacks -ww/command=).
660
+ try {
661
+ const cmdline = await readFileFn(`/proc/${pid}/cmdline`, 'utf8');
662
+ return String(cmdline).replaceAll('\0', ' ');
663
+ } catch {
664
+ return '';
665
+ }
666
+ }
667
+ try {
668
+ const { stdout } = await execFileFn('ps', ['-ww', '-p', String(pid), '-o', 'command=']);
669
+ return stdout;
670
+ } catch {
671
+ return '';
672
+ }
673
+ }
674
+
675
+ /**
676
+ * @param {string} stdout
677
+ * @returns {number[]}
678
+ */
679
+ function parsePidLines(stdout) {
680
+ /** @type {number[]} */
681
+ const pids = [];
682
+ for (const line of stdout.split(/\r?\n/u)) {
683
+ const pid = Number.parseInt(line.trim(), 10);
684
+ if (Number.isInteger(pid) && pid > 0 && !pids.includes(pid)) {
685
+ pids.push(pid);
686
+ }
687
+ }
688
+ return pids;
689
+ }
690
+
483
691
  /**
484
692
  * @param {{
485
693
  * transport: BridgeTransport,
@@ -39,11 +39,7 @@ import {
39
39
  getBridgeTransport,
40
40
  getSocketPath,
41
41
  } from './config.js';
42
- import {
43
- ensureBridgeAuthToken,
44
- normalizeBridgeAuthToken,
45
- readBridgeAuthToken,
46
- } from './auth-token.js';
42
+ import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
47
43
  import { normalizeDaemonLogger } from './daemon-logger.js';
48
44
  import { writeJsonLine } from './framing.js';
49
45
 
@@ -341,7 +337,7 @@ export class BridgeDaemon {
341
337
  return;
342
338
  }
343
339
 
344
- if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
340
+ if (this.isAuthRequired() && !bridgeAuthTokensEqual(message.authToken, this.authToken)) {
345
341
  this.logger.error('socket registration rejected', { role: message.role ?? null });
346
342
  void writeJsonLine(socket, {
347
343
  type: 'registration_failed',