@browserbridge/bbx 1.6.4 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/agent-client/src/cli.js +172 -34
- package/packages/agent-client/src/client.js +44 -7
- package/packages/agent-client/src/command-registry.js +5 -1
- package/packages/agent-client/src/remotes.js +28 -0
- package/packages/agent-client/src/runtime.js +230 -0
- package/packages/agent-client/src/types.ts +21 -0
- package/packages/mcp-server/src/handlers.js +2 -1
- package/packages/mcp-server/src/server.js +4 -1
- package/packages/native-host/bin/bridge-daemon.js +17 -2
- package/packages/native-host/bin/postinstall.js +18 -0
- package/packages/native-host/src/config.js +23 -0
- package/packages/native-host/src/daemon-process.js +138 -4
- package/packages/native-host/src/daemon.js +32 -9
- package/packages/native-host/src/native-host.js +1 -1
- package/packages/protocol/src/protocol.js +56 -7
- package/packages/protocol/src/summary.js +7 -2
- package/skills/browser-bridge/SKILL.md +15 -0
- package/skills/browser-bridge/references/protocol.md +1 -1
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
APP_NAME,
|
|
9
|
+
getBridgeDir,
|
|
10
|
+
getDaemonLogPath,
|
|
11
|
+
getDaemonPidPath,
|
|
8
12
|
getManifestInstallDir,
|
|
13
|
+
getSocketPath,
|
|
9
14
|
SUPPORTED_BROWSERS,
|
|
10
15
|
} from '../../native-host/src/config.js';
|
|
16
|
+
import {
|
|
17
|
+
readDaemonStartHistory,
|
|
18
|
+
summarizeDaemonRestarts,
|
|
19
|
+
} from '../../native-host/src/daemon-process.js';
|
|
11
20
|
import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
|
|
12
21
|
import { methodNeedsTab } from './cli-helpers.js';
|
|
13
22
|
import { BridgeClient } from './client.js';
|
|
@@ -18,6 +27,7 @@ import { BridgeClient } from './client.js';
|
|
|
18
27
|
/** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
|
|
19
28
|
/** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
|
|
20
29
|
/** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
|
|
30
|
+
/** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
|
|
21
31
|
/** @typedef {import('./types.js').DoctorReport} DoctorReport */
|
|
22
32
|
/** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
|
|
23
33
|
|
|
@@ -156,6 +166,195 @@ export async function checkBrowserManifests() {
|
|
|
156
166
|
);
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} line
|
|
171
|
+
* @param {number} startIndex
|
|
172
|
+
* @returns {{ value: string, nextIndex: number } | null}
|
|
173
|
+
*/
|
|
174
|
+
function readShellSingleQuotedToken(line, startIndex) {
|
|
175
|
+
let index = startIndex;
|
|
176
|
+
while (line[index] === ' ') {
|
|
177
|
+
index += 1;
|
|
178
|
+
}
|
|
179
|
+
if (line[index] !== "'") {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
index += 1;
|
|
183
|
+
|
|
184
|
+
let value = '';
|
|
185
|
+
while (index < line.length) {
|
|
186
|
+
const endIndex = line.indexOf("'", index);
|
|
187
|
+
if (endIndex === -1) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
value += line.slice(index, endIndex);
|
|
191
|
+
index = endIndex + 1;
|
|
192
|
+
if (line.startsWith("\\''", index)) {
|
|
193
|
+
value += "'";
|
|
194
|
+
index += 3;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
return { value, nextIndex: index };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} launcher
|
|
205
|
+
* @returns {{ nodePath: string, hostPath: string } | null}
|
|
206
|
+
*/
|
|
207
|
+
function parseNativeHostLauncherTargets(launcher) {
|
|
208
|
+
const windowsMatch = /"([^"\r\n]+)"\s+"([^"\r\n]+)"\s+%\*/u.exec(launcher);
|
|
209
|
+
if (windowsMatch?.[1] && windowsMatch[2]) {
|
|
210
|
+
return {
|
|
211
|
+
nodePath: windowsMatch[1],
|
|
212
|
+
hostPath: windowsMatch[2],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const execLine = launcher
|
|
217
|
+
.split(/\r?\n/u)
|
|
218
|
+
.map((line) => line.trim())
|
|
219
|
+
.find((line) => line.startsWith('exec '));
|
|
220
|
+
if (!execLine) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const nodeToken = readShellSingleQuotedToken(execLine, 'exec '.length);
|
|
224
|
+
if (!nodeToken) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const hostToken = readShellSingleQuotedToken(execLine, nodeToken.nextIndex);
|
|
228
|
+
if (!hostToken) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
nodePath: nodeToken.value,
|
|
233
|
+
hostPath: hostToken.value,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate installed native messaging manifests and the launcher they point to.
|
|
239
|
+
* This catches stale sudo/global Node installs where the manifest exists but
|
|
240
|
+
* Chrome launches a missing or non-executable script/Node binary.
|
|
241
|
+
*
|
|
242
|
+
* @param {BrowserManifestStatus[]} browserManifests
|
|
243
|
+
* @returns {Promise<NativeHostManifestIssue[]>}
|
|
244
|
+
*/
|
|
245
|
+
export async function checkNativeHostManifestHealth(browserManifests) {
|
|
246
|
+
/** @type {NativeHostManifestIssue[]} */
|
|
247
|
+
const issues = [];
|
|
248
|
+
const executableAccessMode = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
|
|
249
|
+
for (const entry of browserManifests) {
|
|
250
|
+
if (!entry.installed) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @type {Record<string, unknown>} */
|
|
255
|
+
let manifest;
|
|
256
|
+
try {
|
|
257
|
+
const rawManifest = await fs.promises.readFile(entry.manifestPath, 'utf8');
|
|
258
|
+
const parsed = JSON.parse(rawManifest);
|
|
259
|
+
manifest = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
issues.push({
|
|
262
|
+
browser: entry.browser,
|
|
263
|
+
manifestPath: entry.manifestPath,
|
|
264
|
+
message: `Native host manifest is not readable JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const launcherPath = typeof manifest.path === 'string' ? manifest.path : '';
|
|
270
|
+
if (!launcherPath) {
|
|
271
|
+
issues.push({
|
|
272
|
+
browser: entry.browser,
|
|
273
|
+
manifestPath: entry.manifestPath,
|
|
274
|
+
message: 'Native host manifest does not specify a launcher path.',
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let launcherRaw = '';
|
|
280
|
+
try {
|
|
281
|
+
await fs.promises.access(launcherPath, executableAccessMode);
|
|
282
|
+
launcherRaw = await fs.promises.readFile(launcherPath, 'utf8');
|
|
283
|
+
} catch (error) {
|
|
284
|
+
issues.push({
|
|
285
|
+
browser: entry.browser,
|
|
286
|
+
manifestPath: entry.manifestPath,
|
|
287
|
+
message: `Native host launcher is not executable or readable at ${launcherPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const launcherTargets = parseNativeHostLauncherTargets(launcherRaw);
|
|
293
|
+
if (!launcherTargets) {
|
|
294
|
+
issues.push({
|
|
295
|
+
browser: entry.browser,
|
|
296
|
+
manifestPath: entry.manifestPath,
|
|
297
|
+
message: `Native host launcher has an unrecognized format at ${launcherPath}. Run \`bbx install --browser ${entry.browser}\` to regenerate it.`,
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
await fs.promises.access(launcherTargets.nodePath, executableAccessMode);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
issues.push({
|
|
306
|
+
browser: entry.browser,
|
|
307
|
+
manifestPath: entry.manifestPath,
|
|
308
|
+
message: `Node executable referenced by the native host launcher is not usable at ${launcherTargets.nodePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await fs.promises.access(launcherTargets.hostPath, fs.constants.F_OK);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
issues.push({
|
|
316
|
+
browser: entry.browser,
|
|
317
|
+
manifestPath: entry.manifestPath,
|
|
318
|
+
message: `Native host script referenced by the launcher is missing at ${launcherTargets.hostPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return issues;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check the bridge home dir and the daemon files inside it for writability.
|
|
327
|
+
* Root-owned files (typically left behind by a sudo install or a sudo `bbx`
|
|
328
|
+
* run) make the daemon crash-loop, so doctor must call them out explicitly.
|
|
329
|
+
*
|
|
330
|
+
* @returns {Promise<string[]>} paths that exist but are not writable
|
|
331
|
+
*/
|
|
332
|
+
export async function checkUnwritableBridgePaths() {
|
|
333
|
+
// The root-owned-files scenario comes from sudo installs, which do not exist
|
|
334
|
+
// on Windows — and fs.access(W_OK) there only reflects the read-only file
|
|
335
|
+
// attribute, so it would produce false positives.
|
|
336
|
+
if (os.platform() === 'win32') {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const candidates = [getBridgeDir(), getDaemonPidPath(), getDaemonLogPath()];
|
|
341
|
+
candidates.push(getSocketPath());
|
|
342
|
+
|
|
343
|
+
/** @type {string[]} */
|
|
344
|
+
const unwritable = [];
|
|
345
|
+
for (const candidate of candidates) {
|
|
346
|
+
try {
|
|
347
|
+
await fs.promises.access(candidate, fs.constants.W_OK);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
350
|
+
if (code !== 'ENOENT') {
|
|
351
|
+
unwritable.push(candidate);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return unwritable;
|
|
356
|
+
}
|
|
357
|
+
|
|
159
358
|
/**
|
|
160
359
|
* @param {DoctorReportOptions} [options={}]
|
|
161
360
|
* @returns {Promise<DoctorReport>}
|
|
@@ -166,6 +365,15 @@ export async function getDoctorReport(options = {}) {
|
|
|
166
365
|
const defaultExtensionId = options.defaultExtensionIdInfo || resolveDefaultExtensionId();
|
|
167
366
|
|
|
168
367
|
const browserManifests = await (options.checkBrowserManifests || checkBrowserManifests)();
|
|
368
|
+
const nativeHostManifestIssues = await (
|
|
369
|
+
options.checkNativeHostManifestHealth || checkNativeHostManifestHealth
|
|
370
|
+
)(browserManifests);
|
|
371
|
+
const daemonRestarts = summarizeDaemonRestarts(
|
|
372
|
+
await (options.readDaemonStartHistory || readDaemonStartHistory)()
|
|
373
|
+
);
|
|
374
|
+
const unwritableBridgePaths = await (
|
|
375
|
+
options.checkUnwritableBridgePaths || checkUnwritableBridgePaths
|
|
376
|
+
)();
|
|
169
377
|
const manifestInstalled = Boolean(manifest) || browserManifests.some((b) => b.installed);
|
|
170
378
|
const chromiumSandboxedManifestInstalled = browserManifests.some(
|
|
171
379
|
(entry) =>
|
|
@@ -188,6 +396,10 @@ export async function getDoctorReport(options = {}) {
|
|
|
188
396
|
routeTabId: null,
|
|
189
397
|
routeReady: false,
|
|
190
398
|
routeReason: 'access_disabled',
|
|
399
|
+
daemonRestarts,
|
|
400
|
+
daemonLogPath: getDaemonLogPath(),
|
|
401
|
+
unwritableBridgePaths,
|
|
402
|
+
nativeHostManifestIssues,
|
|
191
403
|
issues: [],
|
|
192
404
|
nextSteps: [],
|
|
193
405
|
browserManifests,
|
|
@@ -223,6 +435,24 @@ export async function getDoctorReport(options = {}) {
|
|
|
223
435
|
report.extensionConnected = false;
|
|
224
436
|
}
|
|
225
437
|
|
|
438
|
+
if (unwritableBridgePaths.length > 0) {
|
|
439
|
+
report.issues.push('bridge_files_not_writable');
|
|
440
|
+
report.nextSteps.push(
|
|
441
|
+
`These Browser Bridge files are not writable by the current user (usually caused by installing or running bbx with sudo): ${unwritableBridgePaths.join(', ')}. Fix ownership with: sudo chown -R "$USER" "${getBridgeDir()}"`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (nativeHostManifestIssues.length > 0) {
|
|
445
|
+
report.issues.push('native_host_manifest_invalid');
|
|
446
|
+
report.nextSteps.push(
|
|
447
|
+
`The native host manifest or launcher is broken: ${nativeHostManifestIssues.map((issue) => issue.message).join(' ')} Reinstall the native host with \`bbx install --browser <browser>\` or \`bbx install --all\`.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (daemonRestarts.restartLoop) {
|
|
451
|
+
report.issues.push('daemon_restart_loop');
|
|
452
|
+
report.nextSteps.push(
|
|
453
|
+
`The bridge daemon started ${daemonRestarts.startsInWindow} times in the last ${Math.round(daemonRestarts.windowMs / 1000)}s, which means it keeps crashing shortly after startup. Check the daemon log for the underlying error: ${report.daemonLogPath}`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
226
456
|
if (!report.manifestInstalled) {
|
|
227
457
|
report.issues.push('native_host_manifest_missing');
|
|
228
458
|
report.nextSteps.push(
|
|
@@ -98,6 +98,18 @@ export interface BrowserManifestStatus {
|
|
|
98
98
|
installed: boolean;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
export interface DaemonRestartSummary {
|
|
102
|
+
startsInWindow: number;
|
|
103
|
+
windowMs: number;
|
|
104
|
+
restartLoop: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface NativeHostManifestIssue {
|
|
108
|
+
browser: string;
|
|
109
|
+
manifestPath: string;
|
|
110
|
+
message: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
export interface DoctorReport {
|
|
102
114
|
manifestInstalled: boolean;
|
|
103
115
|
manifestPath: string;
|
|
@@ -111,6 +123,10 @@ export interface DoctorReport {
|
|
|
111
123
|
routeTabId: number | null;
|
|
112
124
|
routeReady: boolean;
|
|
113
125
|
routeReason: string;
|
|
126
|
+
daemonRestarts: DaemonRestartSummary;
|
|
127
|
+
daemonLogPath: string;
|
|
128
|
+
unwritableBridgePaths: string[];
|
|
129
|
+
nativeHostManifestIssues: NativeHostManifestIssue[];
|
|
114
130
|
issues: string[];
|
|
115
131
|
nextSteps: string[];
|
|
116
132
|
browserManifests: BrowserManifestStatus[];
|
|
@@ -124,6 +140,11 @@ export interface DoctorReportOptions {
|
|
|
124
140
|
bridgeClientRunner?: <T>(
|
|
125
141
|
callback: (client: { request: BridgeClientRequest }) => Promise<T>
|
|
126
142
|
) => Promise<T>;
|
|
143
|
+
readDaemonStartHistory?: () => Promise<number[]>;
|
|
144
|
+
checkUnwritableBridgePaths?: () => Promise<string[]>;
|
|
145
|
+
checkNativeHostManifestHealth?: (
|
|
146
|
+
browserManifests: BrowserManifestStatus[]
|
|
147
|
+
) => Promise<NativeHostManifestIssue[]>;
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
export type BridgeClientRequest = (options: {
|
|
@@ -168,7 +168,7 @@ export async function handleSetupTool(args) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
* @param {{ limit?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
171
|
+
* @param {{ limit?: number, budgetPreset?: 'quick' | 'normal' | 'deep', destinationId?: string }} args
|
|
172
172
|
* @returns {Promise<ToolResult>}
|
|
173
173
|
*/
|
|
174
174
|
export async function handleLogTool(args) {
|
|
@@ -184,6 +184,7 @@ export async function handleLogTool(args) {
|
|
|
184
184
|
},
|
|
185
185
|
{
|
|
186
186
|
tokenBudget: getToolTokenBudget(normalizedArgs),
|
|
187
|
+
destinationId: typeof args.destinationId === 'string' ? args.destinationId : null,
|
|
187
188
|
}
|
|
188
189
|
);
|
|
189
190
|
}
|
|
@@ -149,6 +149,7 @@ export function createBridgeMcpServer() {
|
|
|
149
149
|
.enum(['quick', 'normal', 'deep'])
|
|
150
150
|
.optional()
|
|
151
151
|
.describe(BUDGET_PRESET_DESCRIPTION),
|
|
152
|
+
destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
|
|
152
153
|
},
|
|
153
154
|
},
|
|
154
155
|
handleLogTool
|
|
@@ -704,7 +705,9 @@ export function createBridgeMcpServer() {
|
|
|
704
705
|
title: 'Request Browser Bridge Access',
|
|
705
706
|
description:
|
|
706
707
|
'Request window access for Browser Bridge. Surfaces an Enable prompt in the extension popup or side panel. Use once per window; if access is already pending, ask the user to enable that window instead of requesting again.',
|
|
707
|
-
inputSchema: {
|
|
708
|
+
inputSchema: {
|
|
709
|
+
destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
|
|
710
|
+
},
|
|
708
711
|
},
|
|
709
712
|
handleAccessTool
|
|
710
713
|
);
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
getBridgeTransport,
|
|
8
8
|
} from '../src/config.js';
|
|
9
9
|
import { DaemonLogger } from '../src/daemon-logger.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
clearDaemonPidFile,
|
|
12
|
+
recordDaemonStart,
|
|
13
|
+
writeDaemonPidFile,
|
|
14
|
+
} from '../src/daemon-process.js';
|
|
11
15
|
|
|
12
16
|
applyWindowsTcpTransportDefaults();
|
|
13
17
|
const transport = getBridgeTransport();
|
|
@@ -25,7 +29,6 @@ function isExistingDaemonError(error) {
|
|
|
25
29
|
|
|
26
30
|
try {
|
|
27
31
|
await daemon.start();
|
|
28
|
-
await writeDaemonPidFile(process.pid);
|
|
29
32
|
} catch (error) {
|
|
30
33
|
if (isExistingDaemonError(error)) {
|
|
31
34
|
process.stdout.write(`${error.message}\n`);
|
|
@@ -35,6 +38,18 @@ try {
|
|
|
35
38
|
process.exit(1);
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
await recordDaemonStart();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await writeDaemonPidFile(process.pid);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// A stale root-owned pid file (e.g. after a sudo install) must not take down
|
|
47
|
+
// a daemon that is already listening; `bbx` falls back to lsof for the pid.
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
`Could not write daemon pid file: ${error instanceof Error ? error.message : String(error)}\n`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
process.stdout.write(`Browser Bridge daemon listening on ${formatBridgeTransport(transport)}\n`);
|
|
39
54
|
|
|
40
55
|
let shuttingDown = false;
|
|
@@ -16,6 +16,18 @@ import { installNativeManifest } from '../src/install-manifest.js';
|
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const repoRoot = path.resolve(__dirname, '../../..');
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* npm exec may run package lifecycle hooks while resolving the local bin. Keep
|
|
21
|
+
* transient `npm exec -- bbx ...` invocations from rewriting host setup or
|
|
22
|
+
* replacing a working daemon.
|
|
23
|
+
*
|
|
24
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function shouldSkipPostinstall(env = process.env) {
|
|
28
|
+
return env.npm_command === 'exec';
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/**
|
|
20
32
|
* @param {{
|
|
21
33
|
* installNativeManifestFn?: typeof installNativeManifest,
|
|
@@ -23,6 +35,7 @@ const repoRoot = path.resolve(__dirname, '../../..');
|
|
|
23
35
|
* stdout?: Pick<NodeJS.WriteStream, 'write'>,
|
|
24
36
|
* stderr?: Pick<NodeJS.WriteStream, 'write'>,
|
|
25
37
|
* exit?: (code?: number) => void,
|
|
38
|
+
* env?: NodeJS.ProcessEnv,
|
|
26
39
|
* }} [deps]
|
|
27
40
|
* @returns {Promise<void>}
|
|
28
41
|
*/
|
|
@@ -32,7 +45,12 @@ export async function runPostinstall({
|
|
|
32
45
|
stdout = process.stdout,
|
|
33
46
|
stderr = process.stderr,
|
|
34
47
|
exit = (code) => process.exit(code),
|
|
48
|
+
env = process.env,
|
|
35
49
|
} = {}) {
|
|
50
|
+
if (shouldSkipPostinstall(env)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
try {
|
|
37
55
|
await installNativeManifestFn({ repoRoot, preserveCustomExtensionId: true });
|
|
38
56
|
stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
|
|
@@ -292,6 +292,29 @@ export function getDaemonPidPath(env = process.env) {
|
|
|
292
292
|
return path.join(getBridgeDir(env), 'daemon.pid');
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Persistent daemon log. The daemon is normally spawned detached with its
|
|
297
|
+
* stdio redirected here so startup failures stay diagnosable after the
|
|
298
|
+
* process is gone.
|
|
299
|
+
*
|
|
300
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
export function getDaemonLogPath(env = process.env) {
|
|
304
|
+
return path.join(getBridgeDir(env), 'daemon.log');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Rolling history of daemon start timestamps, used to detect a daemon that
|
|
309
|
+
* keeps restarting within a short window (crash loop).
|
|
310
|
+
*
|
|
311
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
export function getDaemonStartHistoryPath(env = process.env) {
|
|
315
|
+
return path.join(getBridgeDir(env), 'daemon-starts.json');
|
|
316
|
+
}
|
|
317
|
+
|
|
295
318
|
/**
|
|
296
319
|
* @returns {string}
|
|
297
320
|
*/
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
createSocketBridgeTransport,
|
|
13
13
|
formatBridgeTransport,
|
|
14
14
|
getBridgeTransport,
|
|
15
|
+
getDaemonLogPath,
|
|
15
16
|
getDaemonPidPath,
|
|
17
|
+
getDaemonStartHistoryPath,
|
|
16
18
|
} from './config.js';
|
|
17
19
|
|
|
18
20
|
/** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
|
|
@@ -22,6 +24,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
24
|
const daemonEntryPath = path.resolve(__dirname, '../bin/bridge-daemon.js');
|
|
23
25
|
const DEFAULT_DAEMON_RESTART_TIMEOUT_MS = 5_000;
|
|
24
26
|
const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
|
|
27
|
+
const DAEMON_LOG_MAX_BYTES = 1024 * 1024;
|
|
28
|
+
const DAEMON_START_HISTORY_MAX_ENTRIES = 20;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A daemon that starts this many times within the window is considered to be
|
|
32
|
+
* crash-looping rather than restarting normally.
|
|
33
|
+
*/
|
|
34
|
+
export const DAEMON_RESTART_LOOP_THRESHOLD = 3;
|
|
35
|
+
export const DAEMON_RESTART_LOOP_WINDOW_MS = 60_000;
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
38
|
* @typedef {{
|
|
@@ -56,18 +67,114 @@ const DEFAULT_DAEMON_POLL_INTERVAL_MS = 100;
|
|
|
56
67
|
* }} RestartBridgeDaemonOptions
|
|
57
68
|
*/
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Open the persistent daemon log for appending, rotating it once when it grows
|
|
72
|
+
* past the size cap. Returns null when the log cannot be opened (for example a
|
|
73
|
+
* root-owned bridge dir after a sudo install) so callers can fall back to
|
|
74
|
+
* discarding output instead of failing the spawn.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} [logPath=getDaemonLogPath()]
|
|
77
|
+
* @returns {number | null}
|
|
78
|
+
*/
|
|
79
|
+
export function openDaemonLogFd(logPath = getDaemonLogPath()) {
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
82
|
+
try {
|
|
83
|
+
if (fs.statSync(logPath).size > DAEMON_LOG_MAX_BYTES) {
|
|
84
|
+
fs.rmSync(`${logPath}.1`, { force: true });
|
|
85
|
+
fs.renameSync(logPath, `${logPath}.1`);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Rotation is best-effort: on Windows the rename fails while another
|
|
89
|
+
// daemon holds the log open. Keep appending to the current file.
|
|
90
|
+
}
|
|
91
|
+
return fs.openSync(logPath, 'a');
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
59
97
|
/**
|
|
60
98
|
* @returns {import('node:child_process').ChildProcess}
|
|
61
99
|
*/
|
|
62
100
|
export function spawnBridgeDaemonProcess() {
|
|
101
|
+
const logFd = openDaemonLogFd();
|
|
63
102
|
const child = spawn(process.execPath, [daemonEntryPath], {
|
|
64
103
|
detached: true,
|
|
65
|
-
stdio: 'ignore',
|
|
104
|
+
stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
|
|
66
105
|
});
|
|
67
106
|
child.unref();
|
|
107
|
+
if (logFd !== null) {
|
|
108
|
+
fs.closeSync(logFd);
|
|
109
|
+
}
|
|
68
110
|
return child;
|
|
69
111
|
}
|
|
70
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Read the rolling daemon start history (epoch ms timestamps, oldest first).
|
|
115
|
+
* Missing or malformed files read as an empty history.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} [historyPath=getDaemonStartHistoryPath()]
|
|
118
|
+
* @returns {Promise<number[]>}
|
|
119
|
+
*/
|
|
120
|
+
export async function readDaemonStartHistory(historyPath = getDaemonStartHistoryPath()) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(await fs.promises.readFile(historyPath, 'utf8'));
|
|
123
|
+
if (!Array.isArray(parsed)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return parsed.filter(
|
|
127
|
+
(value) => typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Append a daemon start timestamp to the rolling history. Best-effort: any
|
|
136
|
+
* filesystem failure is swallowed so an unwritable bridge dir never prevents
|
|
137
|
+
* the daemon from starting.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ at?: number, historyPath?: string }} [options={}]
|
|
140
|
+
* @returns {Promise<number[]>} the updated history (empty when persisting failed)
|
|
141
|
+
*/
|
|
142
|
+
export async function recordDaemonStart(options = {}) {
|
|
143
|
+
const { at = Date.now(), historyPath = getDaemonStartHistoryPath() } = options;
|
|
144
|
+
try {
|
|
145
|
+
const history = [...(await readDaemonStartHistory(historyPath)), at].slice(
|
|
146
|
+
-DAEMON_START_HISTORY_MAX_ENTRIES
|
|
147
|
+
);
|
|
148
|
+
await fs.promises.mkdir(path.dirname(historyPath), { recursive: true });
|
|
149
|
+
await fs.promises.writeFile(historyPath, `${JSON.stringify(history)}\n`, 'utf8');
|
|
150
|
+
return history;
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Summarize the daemon start history over the recent window so callers can
|
|
158
|
+
* tell a crash-looping daemon apart from a normal restart.
|
|
159
|
+
*
|
|
160
|
+
* @param {number[]} history
|
|
161
|
+
* @param {{ now?: number, windowMs?: number, threshold?: number }} [options={}]
|
|
162
|
+
* @returns {{ startsInWindow: number, windowMs: number, restartLoop: boolean }}
|
|
163
|
+
*/
|
|
164
|
+
export function summarizeDaemonRestarts(history, options = {}) {
|
|
165
|
+
const {
|
|
166
|
+
now = Date.now(),
|
|
167
|
+
windowMs = DAEMON_RESTART_LOOP_WINDOW_MS,
|
|
168
|
+
threshold = DAEMON_RESTART_LOOP_THRESHOLD,
|
|
169
|
+
} = options;
|
|
170
|
+
const startsInWindow = history.filter((at) => at <= now && now - at <= windowMs).length;
|
|
171
|
+
return {
|
|
172
|
+
startsInWindow,
|
|
173
|
+
windowMs,
|
|
174
|
+
restartLoop: startsInWindow >= threshold,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
71
178
|
/**
|
|
72
179
|
* @param {number} [pid=process.pid]
|
|
73
180
|
* @param {string} [pidPath=getDaemonPidPath()]
|
|
@@ -141,7 +248,8 @@ export async function stopBridgeDaemon(options = {}) {
|
|
|
141
248
|
const resolvedSocketPath =
|
|
142
249
|
resolvedTransport.type === 'socket' ? resolvedTransport.socketPath : '';
|
|
143
250
|
|
|
144
|
-
|
|
251
|
+
const pidFromFile = await readPidFn(pidPath);
|
|
252
|
+
let previousPid = pidFromFile;
|
|
145
253
|
let previouslyRunning = previousPid !== null;
|
|
146
254
|
|
|
147
255
|
if (previousPid === null && (await safePingDaemon(resolvedTransport, pingDaemonFn))) {
|
|
@@ -158,7 +266,7 @@ export async function stopBridgeDaemon(options = {}) {
|
|
|
158
266
|
}
|
|
159
267
|
}
|
|
160
268
|
|
|
161
|
-
|
|
269
|
+
let stopped = await waitForDaemonReachability({
|
|
162
270
|
transport: resolvedTransport,
|
|
163
271
|
reachable: false,
|
|
164
272
|
timeoutMs,
|
|
@@ -166,12 +274,38 @@ export async function stopBridgeDaemon(options = {}) {
|
|
|
166
274
|
pingDaemonFn,
|
|
167
275
|
sleepFn,
|
|
168
276
|
});
|
|
277
|
+
if (!stopped) {
|
|
278
|
+
// The pid file can be stale (e.g. a root-owned leftover from a sudo
|
|
279
|
+
// install that the daemon could not overwrite) while a daemon with a
|
|
280
|
+
// different pid owns the socket. Target the socket owner before giving up.
|
|
281
|
+
const socketOwnerPid = await findPidByTransportFn(resolvedTransport);
|
|
282
|
+
if (socketOwnerPid !== null && socketOwnerPid !== previousPid) {
|
|
283
|
+
try {
|
|
284
|
+
killFn(socketOwnerPid, 'SIGTERM');
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!isMissingProcessError(error)) {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
stopped = await waitForDaemonReachability({
|
|
291
|
+
transport: resolvedTransport,
|
|
292
|
+
reachable: false,
|
|
293
|
+
timeoutMs,
|
|
294
|
+
pollIntervalMs,
|
|
295
|
+
pingDaemonFn,
|
|
296
|
+
sleepFn,
|
|
297
|
+
});
|
|
298
|
+
if (stopped) {
|
|
299
|
+
previousPid = socketOwnerPid;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
169
303
|
if (!stopped) {
|
|
170
304
|
throw new Error(`Timed out waiting for Browser Bridge daemon (pid ${previousPid}) to stop.`);
|
|
171
305
|
}
|
|
172
306
|
}
|
|
173
307
|
|
|
174
|
-
await clearDaemonPidFile({ pid: previousPid, pidPath, rmFn });
|
|
308
|
+
await clearDaemonPidFile({ pid: pidFromFile ?? previousPid, pidPath, rmFn });
|
|
175
309
|
|
|
176
310
|
const removedStaleSocket = await removeStaleSocket(resolvedTransport, rmFn, pingDaemonFn);
|
|
177
311
|
return {
|