@browserbridge/bbx 1.7.6 → 1.7.8

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/PRIVACY.md ADDED
@@ -0,0 +1,92 @@
1
+ # Browser Bridge (BBX) Privacy Policy
2
+
3
+ Last updated: 2026-03-27
4
+
5
+ ## Overview
6
+
7
+ Browser Bridge is a developer tool that lets a user-approved local agent inspect and interact with web pages in an explicitly enabled Chrome window. This policy describes how Browser Bridge handles data.
8
+
9
+ ## What Browser Bridge can access
10
+
11
+ When you enable Browser Bridge for a Chrome window, Browser Bridge can access page data from tabs in that window as needed to fulfill your requests. Depending on the action you or your connected agent triggers, this can include:
12
+
13
+ - Page metadata such as URL, title, viewport, and scroll position
14
+ - DOM structure, element attributes, visible text, and layout information
15
+ - Computed styles, matched rules, and accessibility-tree data
16
+ - Console messages and uncaught page errors
17
+ - Network request metadata captured from page `fetch` and `XMLHttpRequest` activity, such as URL, method, status, timing, and size
18
+ - `localStorage` and `sessionStorage` values when explicitly requested
19
+ - Screenshots or cropped image captures when explicitly requested
20
+ - User-triggered interaction data such as clicks, typing, key presses, selection changes, and navigation actions
21
+
22
+ Browser Bridge is designed for local debugging and automation of the browser state you intentionally expose to it.
23
+
24
+ ## How Browser Bridge uses data
25
+
26
+ Browser Bridge uses this data only to provide its user-facing purpose: letting you inspect, test, and patch the currently enabled browser window from a connected developer tool or agent.
27
+
28
+ The extension sends request and response data locally through:
29
+
30
+ 1. The Browser Bridge Chrome extension
31
+ 2. The Browser Bridge native messaging host running on your machine
32
+ 3. The local agent client or IDE that you intentionally connect
33
+
34
+ Browser Bridge does not operate a Browser Bridge cloud service and does not send extension data to developer-operated remote servers.
35
+
36
+ ## Sharing and downstream processing
37
+
38
+ Browser Bridge shares page data only with the local companion components and local client you choose to connect.
39
+
40
+ Important:
41
+
42
+ - Browser Bridge itself does not sell your data.
43
+ - Browser Bridge itself does not use your data for advertising.
44
+ - Browser Bridge itself does not transfer page data to Browser Bridge-operated remote infrastructure.
45
+ - Your connected agent or IDE may, depending on its own configuration and provider, forward prompts, tool calls, or tool results to remote services. That downstream handling is controlled by the connected product, not by Browser Bridge.
46
+
47
+ You should review the privacy and retention practices of any connected agent, IDE, or model provider you use with Browser Bridge.
48
+
49
+ ## Data retention
50
+
51
+ Browser Bridge keeps data only as long as needed for the current local session and feature behavior.
52
+
53
+ - Window enablement state is stored in session storage and cleared when disabled or when the browser session ends.
54
+ - Action log entries are stored for the current browser session only.
55
+ - Console and network buffers are bounded in memory and cleared when disabled or when the page state is reset.
56
+ - Patch state and remembered element references are session-scoped and cleared on disable, page replacement, or navigation as needed.
57
+
58
+ Browser Bridge does not provide Browser Bridge-hosted accounts, sync, or long-term server-side storage.
59
+
60
+ ## User control
61
+
62
+ You control when Browser Bridge can access browser data.
63
+
64
+ - Access is off by default.
65
+ - You explicitly enable Browser Bridge for a Chrome window.
66
+ - You can disable it at any time.
67
+ - Restricted Chrome pages are not scriptable through Browser Bridge.
68
+
69
+ ## Remote code and runtime evaluation
70
+
71
+ Browser Bridge does not load remotely hosted extension code.
72
+
73
+ Browser Bridge can, when you explicitly invoke features such as page evaluation, execute user-requested expressions in the inspected page context through Chrome's documented Debugger API. That execution affects only the currently enabled browser window and is part of Browser Bridge's developer-tool functionality.
74
+
75
+ ## Security
76
+
77
+ - Extension code is packaged with the extension; Browser Bridge does not fetch and execute remote extension scripts.
78
+ - Communication with the companion host uses Chrome Native Messaging.
79
+ - Access is limited to the explicitly enabled browser window.
80
+ - The extension includes bounded buffers and session-scoped rollback for temporary page patches.
81
+
82
+ ## Chrome Web Store Limited Use statement
83
+
84
+ The use of information received from Google APIs will adhere to the Chrome Web Store User Data Policy, including the Limited Use requirements.
85
+
86
+ ## Changes
87
+
88
+ If Browser Bridge's data practices change, this policy will be updated before those changes are released.
89
+
90
+ ## Contact
91
+
92
+ - GitHub Issues: https://github.com/koltyakov/browser-bridge/issues
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
 
@@ -143,6 +143,10 @@ Browser Bridge itself routes extension data locally through the Chrome extension
143
143
 
144
144
  Your connected agent or IDE may still forward tool calls or tool results to remote services under that product's own settings and privacy policy. See [PRIVACY.md](https://github.com/koltyakov/browser-bridge/blob/main/PRIVACY.md) for the Browser Bridge policy.
145
145
 
146
+ ## Responsible Use
147
+
148
+ Browser Bridge is intended for local development, debugging, and testing of web resources you own, control, or are explicitly authorized to test. Do not use it for web scraping, unauthorized data collection, or impersonating or acting as another user. You are responsible for the agents you connect, the actions they perform, and compliance with applicable laws, permissions, website terms, and third-party rights. See the [Responsible Use Agreement](https://github.com/koltyakov/browser-bridge/blob/main/RESPONSIBLE_USE.md).
149
+
146
150
  ## License
147
151
 
148
152
  MIT. See [LICENSE](https://github.com/koltyakov/browser-bridge/blob/main/LICENSE).
@@ -0,0 +1,16 @@
1
+ # Browser Bridge (BBX) Responsible Use Agreement
2
+
3
+ Last updated: 2026-07-22
4
+
5
+ Browser Bridge is intended for local development, debugging, and testing of websites, applications, and other web resources that you own, control, or have explicit permission to test.
6
+
7
+ By using Browser Bridge, you agree that you will not use it to:
8
+
9
+ - Scrape websites or perform bulk, automated, or unauthorized data collection.
10
+ - Impersonate another user or act through another person's identity, credentials, account, or authenticated session without their explicit authorization.
11
+ - Access systems or data without authorization, bypass access controls, violate privacy, or perform deceptive, fraudulent, abusive, or harmful actions.
12
+ - Violate applicable laws, regulations, contractual obligations, website terms, or third-party rights.
13
+
14
+ You are responsible for the browser windows you enable, the agents and services you connect, the instructions and actions they perform, the data they access or transmit, and any resulting impact. You must obtain all necessary permissions and consent and review the privacy and security practices of connected agents, IDEs, and model providers.
15
+
16
+ Use Browser Bridge fairly, lawfully, and only within an authorized scope. This agreement does not modify the project's [MIT License](./LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,52 @@
1
+ # Security Policy
2
+
3
+ Browser Bridge gives coding agents structured access to a real browser profile. The daemon,
4
+ native messaging host, Chrome extension, CLI, and MCP server are all part of the trusted
5
+ path between an agent and the user's browser, so security reports are taken seriously.
6
+
7
+ ## Supported Versions
8
+
9
+ Only the latest version published to npm (`@browserbridge/bbx`) and the Chrome Web Store
10
+ receives security fixes. Older versions are not patched retroactively; upgrade to the
11
+ latest release before reporting.
12
+
13
+ ## Reporting a Vulnerability
14
+
15
+ Please do **not** open a public GitHub issue for security vulnerabilities.
16
+
17
+ Report privately via GitHub's security advisory form:
18
+ <https://github.com/koltyakov/browser-bridge/security/advisories/new>
19
+
20
+ Include the affected component (daemon, native host, extension, CLI, MCP server), a
21
+ reproduction, and the impact you believe it has. Reports are acknowledged as quickly as
22
+ possible; fixes for confirmed vulnerabilities are prioritized over regular development.
23
+
24
+ ## Scope
25
+
26
+ In scope:
27
+
28
+ - The bridge daemon: transport auth (socket permissions, TCP auth token), request routing,
29
+ and the opt-in proxy/remote mode.
30
+ - The native messaging host and its manifest installation.
31
+ - The Chrome extension (MV3 service worker, content scripts, CDP usage).
32
+ - The `bbx` CLI and MCP server, including config/skill installation paths.
33
+
34
+ Out of scope:
35
+
36
+ - Vulnerabilities in Chrome, Node.js, or other dependencies (report upstream).
37
+ - Issues requiring an already-compromised machine or browser profile.
38
+ - The inherent capability of the tool itself: an agent authorized to use Browser Bridge
39
+ can, by design, read and manipulate pages in the connected browser profile. See
40
+ [PRIVACY.md](https://github.com/koltyakov/browser-bridge/blob/main/PRIVACY.md) and
41
+ [docs/agent-permissions.md](https://github.com/koltyakov/browser-bridge/blob/main/docs/agent-permissions.md)
42
+ for the intended trust model.
43
+
44
+ ## Design Notes
45
+
46
+ - By default the daemon listens on a user-owned Unix socket (mode `0700` directory) or,
47
+ on Windows, a named pipe / localhost TCP.
48
+ - Any TCP listener requires a random 256-bit auth token stored with mode `0600`;
49
+ registration without it is rejected and the comparison is constant-time.
50
+ - Proxy mode (LAN exposure) is opt-in via `bbx proxy enable` and always provisions a
51
+ token; an invalid `bindHost` in the config rejects the config rather than widening
52
+ the bind address.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -33,7 +33,10 @@
33
33
  },
34
34
  "files": [
35
35
  "LICENSE",
36
+ "PRIVACY.md",
37
+ "RESPONSIBLE_USE.md",
36
38
  "README.md",
39
+ "SECURITY.md",
37
40
  "packages/agent-client/src",
38
41
  "packages/mcp-server/src",
39
42
  "packages/native-host/bin",
@@ -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',