@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 +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/src/guidance.js +4 -232
- package/packages/mcp-server/src/server.js +1 -3
- package/packages/native-host/bin/bridge-daemon.js +2 -1
- package/packages/native-host/src/auth-token.js +22 -1
- package/packages/native-host/src/config.js +50 -15
- package/packages/native-host/src/daemon-process.js +218 -10
- package/packages/native-host/src/daemon.js +2 -6
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
|
|
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,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
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
264
|
-
// reachability is the evidence
|
|
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
|
|
451
|
-
return
|
|
459
|
+
export async function findDaemonPidByTransport(transport, options = {}) {
|
|
460
|
+
if (transport.type === 'socket') {
|
|
461
|
+
return findDaemonPidBySocket(transport.socketPath, options);
|
|
452
462
|
}
|
|
453
|
-
return
|
|
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
|
-
|
|
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
|
|
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)
|
|
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() &&
|
|
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',
|