@browserbridge/bbx 1.7.0 → 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 +3 -2
- package/packages/agent-client/src/client.js +44 -7
- package/packages/agent-client/src/runtime.js +230 -0
- package/packages/agent-client/src/types.ts +21 -0
- 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 +12 -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/references/protocol.md +1 -1
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
|
|
|
20
20
|
import { uninstallNativeManifest } from '../../native-host/src/install-manifest.js';
|
|
21
21
|
import {
|
|
22
22
|
createRuntimeContext,
|
|
23
|
+
getProtocolVersion,
|
|
23
24
|
METHODS,
|
|
24
25
|
summarizeBatchErrorItem,
|
|
25
26
|
summarizeBatchResponseItem,
|
|
@@ -628,7 +629,7 @@ async function main() {
|
|
|
628
629
|
evidence: null,
|
|
629
630
|
durationMs: 0,
|
|
630
631
|
approxTokens: 0,
|
|
631
|
-
meta: { protocol_version:
|
|
632
|
+
meta: { protocol_version: getProtocolVersion() },
|
|
632
633
|
error: {
|
|
633
634
|
code: 'INVALID_REQUEST',
|
|
634
635
|
message: 'Each batch call needs a method.',
|
|
@@ -645,7 +646,7 @@ async function main() {
|
|
|
645
646
|
evidence: null,
|
|
646
647
|
durationMs: 0,
|
|
647
648
|
approxTokens: 0,
|
|
648
|
-
meta: { protocol_version:
|
|
649
|
+
meta: { protocol_version: getProtocolVersion() },
|
|
649
650
|
error: {
|
|
650
651
|
code: 'INVALID_REQUEST',
|
|
651
652
|
message: `Unknown bridge method "${call.method}".`,
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { EventEmitter, once } from 'node:events';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
import net from 'node:net';
|
|
5
6
|
import { randomUUID } from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
createRequest,
|
|
9
10
|
DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
10
|
-
|
|
11
|
+
getProtocolVersion,
|
|
11
12
|
parseJsonLines,
|
|
13
|
+
setProtocolPackageVersion,
|
|
12
14
|
} from '../../protocol/src/index.js';
|
|
13
15
|
import {
|
|
14
16
|
createSocketBridgeTransport,
|
|
@@ -26,6 +28,21 @@ import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
|
|
|
26
28
|
/** @typedef {import('./types.js').PendingRequest} PendingRequest */
|
|
27
29
|
/** @typedef {import('./types.js').ProtocolHealthResult} ProtocolHealthResult */
|
|
28
30
|
|
|
31
|
+
setProtocolPackageVersion(loadPackageVersion());
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @returns {string | null}
|
|
35
|
+
*/
|
|
36
|
+
function loadPackageVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return parsed && typeof parsed.version === 'string' ? parsed.version : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
/**
|
|
30
47
|
* @param {string} left
|
|
31
48
|
* @param {string} right
|
|
@@ -57,6 +74,18 @@ function createTimeoutError(method, timeoutMs) {
|
|
|
57
74
|
return error;
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
/**
|
|
78
|
+
* npm exec often runs a checkout-local CLI against an already-connected global
|
|
79
|
+
* daemon. In that mode, protocol drift should warn instead of replacing the
|
|
80
|
+
* user's working daemon and temporarily disconnecting the extension.
|
|
81
|
+
*
|
|
82
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function shouldAutoRestartDaemonOnVersionMismatch(env = process.env) {
|
|
86
|
+
return env.npm_command !== 'exec';
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
/**
|
|
61
90
|
* @param {net.Socket} socket
|
|
62
91
|
* @param {string} line
|
|
@@ -83,7 +112,7 @@ export class BridgeClient extends EventEmitter {
|
|
|
83
112
|
clientId = `agent_${randomUUID()}`,
|
|
84
113
|
defaultTimeoutMs = DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
85
114
|
autoReconnect = false,
|
|
86
|
-
restartDaemonOnVersionMismatch =
|
|
115
|
+
restartDaemonOnVersionMismatch = shouldAutoRestartDaemonOnVersionMismatch(),
|
|
87
116
|
restartDaemonFn = restartBridgeDaemon,
|
|
88
117
|
authToken = undefined,
|
|
89
118
|
} = {}) {
|
|
@@ -390,9 +419,16 @@ export class BridgeClient extends EventEmitter {
|
|
|
390
419
|
? healthResult.supported_versions
|
|
391
420
|
: [];
|
|
392
421
|
const latestRemote = remoteVersions[0];
|
|
422
|
+
if (
|
|
423
|
+
healthResult?.extensionConnected === true &&
|
|
424
|
+
Array.isArray(healthResult.supported_versions) &&
|
|
425
|
+
!healthResult.supported_versions.includes(getProtocolVersion())
|
|
426
|
+
) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
393
429
|
return (
|
|
394
430
|
typeof latestRemote === 'string' &&
|
|
395
|
-
compareProtocolVersions(latestRemote,
|
|
431
|
+
compareProtocolVersions(latestRemote, getProtocolVersion()) < 0
|
|
396
432
|
);
|
|
397
433
|
}
|
|
398
434
|
|
|
@@ -436,20 +472,21 @@ export class BridgeClient extends EventEmitter {
|
|
|
436
472
|
if (remoteVersions.length === 0) {
|
|
437
473
|
return {
|
|
438
474
|
compatible: true,
|
|
439
|
-
localVersion:
|
|
475
|
+
localVersion: getProtocolVersion(),
|
|
440
476
|
remoteVersions,
|
|
441
477
|
};
|
|
442
478
|
}
|
|
443
|
-
const
|
|
479
|
+
const localVersion = getProtocolVersion();
|
|
480
|
+
const compatible = remoteVersions.includes(localVersion);
|
|
444
481
|
return {
|
|
445
482
|
compatible,
|
|
446
|
-
localVersion
|
|
483
|
+
localVersion,
|
|
447
484
|
remoteVersions,
|
|
448
485
|
...(!compatible && {
|
|
449
486
|
warning:
|
|
450
487
|
typeof healthResult?.migration_hint === 'string' && healthResult.migration_hint
|
|
451
488
|
? healthResult.migration_hint
|
|
452
|
-
: `Protocol mismatch: client speaks ${
|
|
489
|
+
: `Protocol mismatch: client speaks ${localVersion} but remote supports [${remoteVersions.join(', ')}]. Update the ${remoteVersions[0] > localVersion ? 'client (npm)' : 'extension'} to match.`,
|
|
453
490
|
}),
|
|
454
491
|
};
|
|
455
492
|
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
APP_NAME,
|
|
9
|
+
getBridgeDir,
|
|
10
|
+
getDaemonLogPath,
|
|
11
|
+
getDaemonPidPath,
|
|
8
12
|
getManifestInstallDir,
|
|
13
|
+
getSocketPath,
|
|
9
14
|
SUPPORTED_BROWSERS,
|
|
10
15
|
} from '../../native-host/src/config.js';
|
|
16
|
+
import {
|
|
17
|
+
readDaemonStartHistory,
|
|
18
|
+
summarizeDaemonRestarts,
|
|
19
|
+
} from '../../native-host/src/daemon-process.js';
|
|
11
20
|
import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
|
|
12
21
|
import { methodNeedsTab } from './cli-helpers.js';
|
|
13
22
|
import { BridgeClient } from './client.js';
|
|
@@ -18,6 +27,7 @@ import { BridgeClient } from './client.js';
|
|
|
18
27
|
/** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
|
|
19
28
|
/** @typedef {import('../../native-host/src/config.js').SupportedBrowser} SupportedBrowser */
|
|
20
29
|
/** @typedef {import('./types.js').BrowserManifestStatus} BrowserManifestStatus */
|
|
30
|
+
/** @typedef {import('./types.js').NativeHostManifestIssue} NativeHostManifestIssue */
|
|
21
31
|
/** @typedef {import('./types.js').DoctorReport} DoctorReport */
|
|
22
32
|
/** @typedef {import('./types.js').DoctorReportOptions} DoctorReportOptions */
|
|
23
33
|
|
|
@@ -156,6 +166,195 @@ export async function checkBrowserManifests() {
|
|
|
156
166
|
);
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} line
|
|
171
|
+
* @param {number} startIndex
|
|
172
|
+
* @returns {{ value: string, nextIndex: number } | null}
|
|
173
|
+
*/
|
|
174
|
+
function readShellSingleQuotedToken(line, startIndex) {
|
|
175
|
+
let index = startIndex;
|
|
176
|
+
while (line[index] === ' ') {
|
|
177
|
+
index += 1;
|
|
178
|
+
}
|
|
179
|
+
if (line[index] !== "'") {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
index += 1;
|
|
183
|
+
|
|
184
|
+
let value = '';
|
|
185
|
+
while (index < line.length) {
|
|
186
|
+
const endIndex = line.indexOf("'", index);
|
|
187
|
+
if (endIndex === -1) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
value += line.slice(index, endIndex);
|
|
191
|
+
index = endIndex + 1;
|
|
192
|
+
if (line.startsWith("\\''", index)) {
|
|
193
|
+
value += "'";
|
|
194
|
+
index += 3;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
return { value, nextIndex: index };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} launcher
|
|
205
|
+
* @returns {{ nodePath: string, hostPath: string } | null}
|
|
206
|
+
*/
|
|
207
|
+
function parseNativeHostLauncherTargets(launcher) {
|
|
208
|
+
const windowsMatch = /"([^"\r\n]+)"\s+"([^"\r\n]+)"\s+%\*/u.exec(launcher);
|
|
209
|
+
if (windowsMatch?.[1] && windowsMatch[2]) {
|
|
210
|
+
return {
|
|
211
|
+
nodePath: windowsMatch[1],
|
|
212
|
+
hostPath: windowsMatch[2],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const execLine = launcher
|
|
217
|
+
.split(/\r?\n/u)
|
|
218
|
+
.map((line) => line.trim())
|
|
219
|
+
.find((line) => line.startsWith('exec '));
|
|
220
|
+
if (!execLine) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const nodeToken = readShellSingleQuotedToken(execLine, 'exec '.length);
|
|
224
|
+
if (!nodeToken) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const hostToken = readShellSingleQuotedToken(execLine, nodeToken.nextIndex);
|
|
228
|
+
if (!hostToken) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
nodePath: nodeToken.value,
|
|
233
|
+
hostPath: hostToken.value,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate installed native messaging manifests and the launcher they point to.
|
|
239
|
+
* This catches stale sudo/global Node installs where the manifest exists but
|
|
240
|
+
* Chrome launches a missing or non-executable script/Node binary.
|
|
241
|
+
*
|
|
242
|
+
* @param {BrowserManifestStatus[]} browserManifests
|
|
243
|
+
* @returns {Promise<NativeHostManifestIssue[]>}
|
|
244
|
+
*/
|
|
245
|
+
export async function checkNativeHostManifestHealth(browserManifests) {
|
|
246
|
+
/** @type {NativeHostManifestIssue[]} */
|
|
247
|
+
const issues = [];
|
|
248
|
+
const executableAccessMode = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
|
|
249
|
+
for (const entry of browserManifests) {
|
|
250
|
+
if (!entry.installed) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @type {Record<string, unknown>} */
|
|
255
|
+
let manifest;
|
|
256
|
+
try {
|
|
257
|
+
const rawManifest = await fs.promises.readFile(entry.manifestPath, 'utf8');
|
|
258
|
+
const parsed = JSON.parse(rawManifest);
|
|
259
|
+
manifest = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
issues.push({
|
|
262
|
+
browser: entry.browser,
|
|
263
|
+
manifestPath: entry.manifestPath,
|
|
264
|
+
message: `Native host manifest is not readable JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const launcherPath = typeof manifest.path === 'string' ? manifest.path : '';
|
|
270
|
+
if (!launcherPath) {
|
|
271
|
+
issues.push({
|
|
272
|
+
browser: entry.browser,
|
|
273
|
+
manifestPath: entry.manifestPath,
|
|
274
|
+
message: 'Native host manifest does not specify a launcher path.',
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let launcherRaw = '';
|
|
280
|
+
try {
|
|
281
|
+
await fs.promises.access(launcherPath, executableAccessMode);
|
|
282
|
+
launcherRaw = await fs.promises.readFile(launcherPath, 'utf8');
|
|
283
|
+
} catch (error) {
|
|
284
|
+
issues.push({
|
|
285
|
+
browser: entry.browser,
|
|
286
|
+
manifestPath: entry.manifestPath,
|
|
287
|
+
message: `Native host launcher is not executable or readable at ${launcherPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const launcherTargets = parseNativeHostLauncherTargets(launcherRaw);
|
|
293
|
+
if (!launcherTargets) {
|
|
294
|
+
issues.push({
|
|
295
|
+
browser: entry.browser,
|
|
296
|
+
manifestPath: entry.manifestPath,
|
|
297
|
+
message: `Native host launcher has an unrecognized format at ${launcherPath}. Run \`bbx install --browser ${entry.browser}\` to regenerate it.`,
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
await fs.promises.access(launcherTargets.nodePath, executableAccessMode);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
issues.push({
|
|
306
|
+
browser: entry.browser,
|
|
307
|
+
manifestPath: entry.manifestPath,
|
|
308
|
+
message: `Node executable referenced by the native host launcher is not usable at ${launcherTargets.nodePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await fs.promises.access(launcherTargets.hostPath, fs.constants.F_OK);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
issues.push({
|
|
316
|
+
browser: entry.browser,
|
|
317
|
+
manifestPath: entry.manifestPath,
|
|
318
|
+
message: `Native host script referenced by the launcher is missing at ${launcherTargets.hostPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return issues;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check the bridge home dir and the daemon files inside it for writability.
|
|
327
|
+
* Root-owned files (typically left behind by a sudo install or a sudo `bbx`
|
|
328
|
+
* run) make the daemon crash-loop, so doctor must call them out explicitly.
|
|
329
|
+
*
|
|
330
|
+
* @returns {Promise<string[]>} paths that exist but are not writable
|
|
331
|
+
*/
|
|
332
|
+
export async function checkUnwritableBridgePaths() {
|
|
333
|
+
// The root-owned-files scenario comes from sudo installs, which do not exist
|
|
334
|
+
// on Windows — and fs.access(W_OK) there only reflects the read-only file
|
|
335
|
+
// attribute, so it would produce false positives.
|
|
336
|
+
if (os.platform() === 'win32') {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const candidates = [getBridgeDir(), getDaemonPidPath(), getDaemonLogPath()];
|
|
341
|
+
candidates.push(getSocketPath());
|
|
342
|
+
|
|
343
|
+
/** @type {string[]} */
|
|
344
|
+
const unwritable = [];
|
|
345
|
+
for (const candidate of candidates) {
|
|
346
|
+
try {
|
|
347
|
+
await fs.promises.access(candidate, fs.constants.W_OK);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
350
|
+
if (code !== 'ENOENT') {
|
|
351
|
+
unwritable.push(candidate);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return unwritable;
|
|
356
|
+
}
|
|
357
|
+
|
|
159
358
|
/**
|
|
160
359
|
* @param {DoctorReportOptions} [options={}]
|
|
161
360
|
* @returns {Promise<DoctorReport>}
|
|
@@ -166,6 +365,15 @@ export async function getDoctorReport(options = {}) {
|
|
|
166
365
|
const defaultExtensionId = options.defaultExtensionIdInfo || resolveDefaultExtensionId();
|
|
167
366
|
|
|
168
367
|
const browserManifests = await (options.checkBrowserManifests || checkBrowserManifests)();
|
|
368
|
+
const nativeHostManifestIssues = await (
|
|
369
|
+
options.checkNativeHostManifestHealth || checkNativeHostManifestHealth
|
|
370
|
+
)(browserManifests);
|
|
371
|
+
const daemonRestarts = summarizeDaemonRestarts(
|
|
372
|
+
await (options.readDaemonStartHistory || readDaemonStartHistory)()
|
|
373
|
+
);
|
|
374
|
+
const unwritableBridgePaths = await (
|
|
375
|
+
options.checkUnwritableBridgePaths || checkUnwritableBridgePaths
|
|
376
|
+
)();
|
|
169
377
|
const manifestInstalled = Boolean(manifest) || browserManifests.some((b) => b.installed);
|
|
170
378
|
const chromiumSandboxedManifestInstalled = browserManifests.some(
|
|
171
379
|
(entry) =>
|
|
@@ -188,6 +396,10 @@ export async function getDoctorReport(options = {}) {
|
|
|
188
396
|
routeTabId: null,
|
|
189
397
|
routeReady: false,
|
|
190
398
|
routeReason: 'access_disabled',
|
|
399
|
+
daemonRestarts,
|
|
400
|
+
daemonLogPath: getDaemonLogPath(),
|
|
401
|
+
unwritableBridgePaths,
|
|
402
|
+
nativeHostManifestIssues,
|
|
191
403
|
issues: [],
|
|
192
404
|
nextSteps: [],
|
|
193
405
|
browserManifests,
|
|
@@ -223,6 +435,24 @@ export async function getDoctorReport(options = {}) {
|
|
|
223
435
|
report.extensionConnected = false;
|
|
224
436
|
}
|
|
225
437
|
|
|
438
|
+
if (unwritableBridgePaths.length > 0) {
|
|
439
|
+
report.issues.push('bridge_files_not_writable');
|
|
440
|
+
report.nextSteps.push(
|
|
441
|
+
`These Browser Bridge files are not writable by the current user (usually caused by installing or running bbx with sudo): ${unwritableBridgePaths.join(', ')}. Fix ownership with: sudo chown -R "$USER" "${getBridgeDir()}"`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (nativeHostManifestIssues.length > 0) {
|
|
445
|
+
report.issues.push('native_host_manifest_invalid');
|
|
446
|
+
report.nextSteps.push(
|
|
447
|
+
`The native host manifest or launcher is broken: ${nativeHostManifestIssues.map((issue) => issue.message).join(' ')} Reinstall the native host with \`bbx install --browser <browser>\` or \`bbx install --all\`.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (daemonRestarts.restartLoop) {
|
|
451
|
+
report.issues.push('daemon_restart_loop');
|
|
452
|
+
report.nextSteps.push(
|
|
453
|
+
`The bridge daemon started ${daemonRestarts.startsInWindow} times in the last ${Math.round(daemonRestarts.windowMs / 1000)}s, which means it keeps crashing shortly after startup. Check the daemon log for the underlying error: ${report.daemonLogPath}`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
226
456
|
if (!report.manifestInstalled) {
|
|
227
457
|
report.issues.push('native_host_manifest_missing');
|
|
228
458
|
report.nextSteps.push(
|
|
@@ -98,6 +98,18 @@ export interface BrowserManifestStatus {
|
|
|
98
98
|
installed: boolean;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
export interface DaemonRestartSummary {
|
|
102
|
+
startsInWindow: number;
|
|
103
|
+
windowMs: number;
|
|
104
|
+
restartLoop: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface NativeHostManifestIssue {
|
|
108
|
+
browser: string;
|
|
109
|
+
manifestPath: string;
|
|
110
|
+
message: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
export interface DoctorReport {
|
|
102
114
|
manifestInstalled: boolean;
|
|
103
115
|
manifestPath: string;
|
|
@@ -111,6 +123,10 @@ export interface DoctorReport {
|
|
|
111
123
|
routeTabId: number | null;
|
|
112
124
|
routeReady: boolean;
|
|
113
125
|
routeReason: string;
|
|
126
|
+
daemonRestarts: DaemonRestartSummary;
|
|
127
|
+
daemonLogPath: string;
|
|
128
|
+
unwritableBridgePaths: string[];
|
|
129
|
+
nativeHostManifestIssues: NativeHostManifestIssue[];
|
|
114
130
|
issues: string[];
|
|
115
131
|
nextSteps: string[];
|
|
116
132
|
browserManifests: BrowserManifestStatus[];
|
|
@@ -124,6 +140,11 @@ export interface DoctorReportOptions {
|
|
|
124
140
|
bridgeClientRunner?: <T>(
|
|
125
141
|
callback: (client: { request: BridgeClientRequest }) => Promise<T>
|
|
126
142
|
) => Promise<T>;
|
|
143
|
+
readDaemonStartHistory?: () => Promise<number[]>;
|
|
144
|
+
checkUnwritableBridgePaths?: () => Promise<string[]>;
|
|
145
|
+
checkNativeHostManifestHealth?: (
|
|
146
|
+
browserManifests: BrowserManifestStatus[]
|
|
147
|
+
) => Promise<NativeHostManifestIssue[]>;
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
export type BridgeClientRequest = (options: {
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
getBridgeTransport,
|
|
8
8
|
} from '../src/config.js';
|
|
9
9
|
import { DaemonLogger } from '../src/daemon-logger.js';
|
|
10
|
-
import {
|
|
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 {
|
|
@@ -24,9 +24,10 @@ import {
|
|
|
24
24
|
DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
|
|
25
25
|
DEFAULT_LOG_TAIL_LIMIT,
|
|
26
26
|
ERROR_CODES,
|
|
27
|
+
getProtocolVersion,
|
|
28
|
+
getSupportedProtocolVersions,
|
|
27
29
|
parseJsonLines,
|
|
28
|
-
|
|
29
|
-
SUPPORTED_VERSIONS,
|
|
30
|
+
setProtocolPackageVersion,
|
|
30
31
|
validateBridgeRequest,
|
|
31
32
|
} from '../../protocol/src/index.js';
|
|
32
33
|
import {
|
|
@@ -45,6 +46,7 @@ import { normalizeDaemonLogger } from './daemon-logger.js';
|
|
|
45
46
|
import { writeJsonLine } from './framing.js';
|
|
46
47
|
|
|
47
48
|
const DAEMON_VERSION = loadDaemonVersion();
|
|
49
|
+
setProtocolPackageVersion(DAEMON_VERSION);
|
|
48
50
|
|
|
49
51
|
/** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
|
|
50
52
|
/** @typedef {import('../../protocol/src/types.js').SetupInstallParams} SetupInstallParams */
|
|
@@ -89,14 +91,15 @@ function compareProtocolVersions(left, right) {
|
|
|
89
91
|
* @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
|
|
90
92
|
*/
|
|
91
93
|
function getVersionNegotiationPayload(requestedVersion) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const supportedVersions = getSupportedProtocolVersions();
|
|
95
|
+
const latestSupported = supportedVersions[0];
|
|
96
|
+
if (!requestedVersion || !latestSupported || supportedVersions.includes(requestedVersion)) {
|
|
97
|
+
return { supported_versions: supportedVersions };
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
const localIsNewer = compareProtocolVersions(latestSupported, requestedVersion) > 0;
|
|
98
101
|
return {
|
|
99
|
-
supported_versions:
|
|
102
|
+
supported_versions: supportedVersions,
|
|
100
103
|
...(localIsNewer ? { deprecated_since: latestSupported } : {}),
|
|
101
104
|
migration_hint: localIsNewer
|
|
102
105
|
? `Browser Bridge daemon is newer than the client protocol ${requestedVersion}. Restart or update the Browser Bridge CLI/npm package to ${latestSupported} or later.`
|
|
@@ -640,7 +643,7 @@ export class BridgeDaemon {
|
|
|
640
643
|
transport: formatBridgeTransport(this.transport),
|
|
641
644
|
proxy: this.getProxyStatusPayload(),
|
|
642
645
|
connectedExtensions: [],
|
|
643
|
-
daemon_supported_versions:
|
|
646
|
+
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
644
647
|
...getVersionNegotiationPayload(request.meta?.protocol_version),
|
|
645
648
|
});
|
|
646
649
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
@@ -948,7 +951,7 @@ export class BridgeDaemon {
|
|
|
948
951
|
...getVersionNegotiationPayload(pending.protocolVersion),
|
|
949
952
|
.../** @type {Record<string, unknown>} */ (responseMessage.result),
|
|
950
953
|
daemonVersion: DAEMON_VERSION,
|
|
951
|
-
daemon_supported_versions:
|
|
954
|
+
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
952
955
|
proxy: this.getProxyStatusPayload(),
|
|
953
956
|
},
|
|
954
957
|
{
|
|
@@ -1161,7 +1164,7 @@ export async function pingExistingDaemon(transport) {
|
|
|
1161
1164
|
method: 'health.ping',
|
|
1162
1165
|
tab_id: null,
|
|
1163
1166
|
params: {},
|
|
1164
|
-
meta: { protocol_version:
|
|
1167
|
+
meta: { protocol_version: getProtocolVersion(), token_budget: null },
|
|
1165
1168
|
},
|
|
1166
1169
|
})}\n`
|
|
1167
1170
|
);
|
|
@@ -125,7 +125,7 @@ export async function runNativeHost({
|
|
|
125
125
|
socket = await connectWithBootstrap(resolvedTransport);
|
|
126
126
|
} catch (error) {
|
|
127
127
|
await writeNativeMessageQueued({
|
|
128
|
-
type: '
|
|
128
|
+
type: 'host.bridge_response',
|
|
129
129
|
response: createFailure(
|
|
130
130
|
'native_bootstrap',
|
|
131
131
|
ERROR_CODES.NATIVE_HOST_UNAVAILABLE,
|
|
@@ -82,10 +82,59 @@ import { BRIDGE_METHODS, METHOD_SET, createBridgeMethodGroups } from './registry
|
|
|
82
82
|
/** @typedef {import('./types.js').WaitForLoadStateParams} WaitForLoadStateParams */
|
|
83
83
|
/** @typedef {import('./types.js').WaitForParams} WaitForParams */
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
/** @type {string | null} */
|
|
86
|
+
let protocolPackageVersion = null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string | null | undefined} packageVersion
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function deriveProtocolVersion(packageVersion) {
|
|
93
|
+
const match = typeof packageVersion === 'string' ? /^(\d+)\.(\d+)/.exec(packageVersion) : null;
|
|
94
|
+
return match ? `${match[1]}.${match[2]}` : '0.0';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @returns {string | null}
|
|
99
|
+
*/
|
|
100
|
+
function getRuntimePackageVersion() {
|
|
101
|
+
const chromeRuntime = /** @type {{ runtime?: { getManifest?: () => { version?: unknown } } }} */ (
|
|
102
|
+
globalThis.chrome ?? {}
|
|
103
|
+
).runtime;
|
|
104
|
+
const manifestVersion = chromeRuntime?.getManifest?.().version;
|
|
105
|
+
if (typeof manifestVersion === 'string') {
|
|
106
|
+
return manifestVersion;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const npmPackageVersion = globalThis.process?.env?.npm_package_version;
|
|
110
|
+
return typeof npmPackageVersion === 'string' ? npmPackageVersion : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string | null | undefined} packageVersion
|
|
115
|
+
* @returns {void}
|
|
116
|
+
*/
|
|
117
|
+
export function setProtocolPackageVersion(packageVersion) {
|
|
118
|
+
protocolPackageVersion = typeof packageVersion === 'string' ? packageVersion : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function getProtocolVersion() {
|
|
125
|
+
return deriveProtocolVersion(protocolPackageVersion ?? getRuntimePackageVersion());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Snapshot retained for older callers; new code should call getProtocolVersion(). */
|
|
129
|
+
export const PROTOCOL_VERSION = getProtocolVersion();
|
|
86
130
|
|
|
87
131
|
/** Versions this build can speak. Newest first. */
|
|
88
|
-
export
|
|
132
|
+
export function getSupportedProtocolVersions() {
|
|
133
|
+
return Object.freeze([getProtocolVersion()]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Snapshot retained for older callers; new code should call getSupportedProtocolVersions(). */
|
|
137
|
+
export const SUPPORTED_VERSIONS = getSupportedProtocolVersions();
|
|
89
138
|
|
|
90
139
|
/**
|
|
91
140
|
* Clamp a numeric value between min and max, falling back to a default.
|
|
@@ -122,7 +171,7 @@ export function createRequest({ id, method, tabId = null, params = {}, meta = {}
|
|
|
122
171
|
tab_id: tabId,
|
|
123
172
|
params,
|
|
124
173
|
meta: {
|
|
125
|
-
protocol_version:
|
|
174
|
+
protocol_version: getProtocolVersion(),
|
|
126
175
|
...meta,
|
|
127
176
|
},
|
|
128
177
|
});
|
|
@@ -141,7 +190,7 @@ export function createSuccess(id, result, meta = {}) {
|
|
|
141
190
|
result,
|
|
142
191
|
error: null,
|
|
143
192
|
meta: {
|
|
144
|
-
protocol_version:
|
|
193
|
+
protocol_version: getProtocolVersion(),
|
|
145
194
|
...meta,
|
|
146
195
|
},
|
|
147
196
|
};
|
|
@@ -168,7 +217,7 @@ export function createFailure(id, code, message, details = null, meta = {}) {
|
|
|
168
217
|
...(recovery && { recovery }),
|
|
169
218
|
},
|
|
170
219
|
meta: {
|
|
171
|
-
protocol_version:
|
|
220
|
+
protocol_version: getProtocolVersion(),
|
|
172
221
|
...meta,
|
|
173
222
|
},
|
|
174
223
|
};
|
|
@@ -238,7 +287,7 @@ export function validateBridgeRequest(request) {
|
|
|
238
287
|
meta: {
|
|
239
288
|
...meta,
|
|
240
289
|
protocol_version:
|
|
241
|
-
typeof meta.protocol_version === 'string' ? meta.protocol_version :
|
|
290
|
+
typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
|
|
242
291
|
token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
|
|
243
292
|
source: meta.source === 'cli' || meta.source === 'mcp' ? meta.source : undefined,
|
|
244
293
|
},
|
|
@@ -842,7 +891,7 @@ export function createRuntimeContext() {
|
|
|
842
891
|
const methodGroups = createBridgeMethodGroups();
|
|
843
892
|
|
|
844
893
|
return {
|
|
845
|
-
v:
|
|
894
|
+
v: getProtocolVersion(),
|
|
846
895
|
budgets: {
|
|
847
896
|
quick: {
|
|
848
897
|
n: BUDGET_PRESETS.quick.maxNodes,
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
estimateSerializedPayloadCost,
|
|
5
|
+
getCostClass,
|
|
6
|
+
getProtocolVersion,
|
|
7
|
+
serializeJsonPayload,
|
|
8
|
+
} from './index.js';
|
|
4
9
|
|
|
5
10
|
/** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
|
|
6
11
|
/** @typedef {import('./types.js').BridgeMethod} SummaryMethod */
|
|
@@ -692,7 +697,7 @@ export function summarizeBatchErrorItem({ method, tabId, error, durationMs }) {
|
|
|
692
697
|
message,
|
|
693
698
|
details: null,
|
|
694
699
|
},
|
|
695
|
-
meta: { protocol_version:
|
|
700
|
+
meta: { protocol_version: getProtocolVersion() },
|
|
696
701
|
});
|
|
697
702
|
const summary = annotateBridgeSummary(summarizeBridgeResponse(response, method), response);
|
|
698
703
|
return {
|
|
@@ -348,7 +348,7 @@ bbx call screenshot.capture_full_page '{}'
|
|
|
348
348
|
"tab_id": 123,
|
|
349
349
|
"method": "dom.query",
|
|
350
350
|
"params": {},
|
|
351
|
-
"meta": { "protocol_version": "
|
|
351
|
+
"meta": { "protocol_version": "<package major.minor>", "token_budget": 1200 }
|
|
352
352
|
}
|
|
353
353
|
```
|
|
354
354
|
|