@browserbridge/bbx 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +29 -3
- package/README.md +10 -3
- package/SECURITY.md +12 -0
- package/package.json +7 -1
- package/packages/agent-client/src/cli-args.js +35 -0
- package/packages/agent-client/src/cli-batch.js +121 -58
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli.js +227 -19
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +8 -2
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/npm-self-update.js +305 -0
- package/packages/agent-client/src/remotes.js +4 -2
- package/packages/agent-client/src/runtime.js +97 -5
- package/packages/agent-client/src/types.ts +38 -0
- package/packages/mcp-server/src/handlers-capture.js +29 -2
- package/packages/mcp-server/src/handlers-dom.js +54 -3
- package/packages/mcp-server/src/handlers-page.js +180 -60
- package/packages/mcp-server/src/handlers-utils.js +35 -4
- package/packages/mcp-server/src/handlers.js +10 -3
- package/packages/mcp-server/src/server.js +75 -6
- package/packages/native-host/src/artifact-store.js +353 -0
- package/packages/native-host/src/config.js +9 -1
- package/packages/native-host/src/daemon-logger.js +3 -1
- package/packages/native-host/src/daemon.js +543 -27
- package/packages/native-host/src/native-host.js +25 -2
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +28 -0
- package/packages/protocol/src/errors.js +48 -5
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +143 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +489 -4
- package/skills/browser-bridge/SKILL.md +22 -8
- package/skills/browser-bridge/references/protocol.md +117 -13
- package/skills/browser-bridge/references/token-efficiency.md +17 -7
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { getBridgeDir, getBridgeTransport } from '../../native-host/src/config.js';
|
|
7
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./types.js').AutoUpdatePolicy} AutoUpdatePolicy */
|
|
10
|
+
/** @typedef {import('./types.js').BridgeClientOptions} BridgeClientOptions */
|
|
11
|
+
/** @typedef {import('./types.js').BrowserBridgeConfig} BrowserBridgeConfig */
|
|
12
|
+
|
|
13
|
+
export const AUTO_UPDATE_ENV = 'BBX_AUTO_UPDATE';
|
|
14
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
15
|
+
const AUTO_UPDATE_POLICIES = new Set(['off', 'compatible']);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function getBridgeConfigPath(env = process.env) {
|
|
22
|
+
return path.join(getBridgeDir(env), CONFIG_FILENAME);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {AutoUpdatePolicy}
|
|
28
|
+
*/
|
|
29
|
+
export function parseAutoUpdatePolicy(value) {
|
|
30
|
+
if (typeof value !== 'string' || !AUTO_UPDATE_POLICIES.has(value)) {
|
|
31
|
+
throw new Error('Auto-update policy must be "off" or "compatible".');
|
|
32
|
+
}
|
|
33
|
+
return /** @type {AutoUpdatePolicy} */ (value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
38
|
+
* @returns {Promise<BrowserBridgeConfig>}
|
|
39
|
+
*/
|
|
40
|
+
export async function readBridgeConfig(env = process.env) {
|
|
41
|
+
const configPath = getBridgeConfigPath(env);
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await fs.promises.readFile(configPath, 'utf8');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isMissingFileError(error)) {
|
|
47
|
+
return { autoUpdate: 'off' };
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Cannot read ${configPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
61
|
+
throw new Error(`Cannot read ${configPath}: expected a JSON object.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const config = /** @type {Record<string, unknown>} */ (parsed);
|
|
65
|
+
return {
|
|
66
|
+
...config,
|
|
67
|
+
autoUpdate: config.autoUpdate === undefined ? 'off' : parseAutoUpdatePolicy(config.autoUpdate),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {AutoUpdatePolicy} policy
|
|
73
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
74
|
+
* @returns {Promise<string>}
|
|
75
|
+
*/
|
|
76
|
+
export async function setAutoUpdatePolicy(policy, env = process.env) {
|
|
77
|
+
const normalized = parseAutoUpdatePolicy(policy);
|
|
78
|
+
const configPath = getBridgeConfigPath(env);
|
|
79
|
+
const config = await readBridgeConfig(env);
|
|
80
|
+
await atomicWriteFile(
|
|
81
|
+
configPath,
|
|
82
|
+
`${JSON.stringify({ ...config, autoUpdate: normalized }, null, 2)}\n`,
|
|
83
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
84
|
+
);
|
|
85
|
+
return configPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Environment configuration overrides the persisted preference for unattended
|
|
90
|
+
* and containerized clients.
|
|
91
|
+
*
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
93
|
+
* @returns {Promise<AutoUpdatePolicy>}
|
|
94
|
+
*/
|
|
95
|
+
export async function getAutoUpdatePolicy(env = process.env) {
|
|
96
|
+
if (env[AUTO_UPDATE_ENV] !== undefined) {
|
|
97
|
+
return parseAutoUpdatePolicy(env[AUTO_UPDATE_ENV]);
|
|
98
|
+
}
|
|
99
|
+
return (await readBridgeConfig(env)).autoUpdate;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {BridgeClientOptions} [options={}]
|
|
104
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
105
|
+
* @param {string[]} [argv=process.argv]
|
|
106
|
+
* @returns {Promise<BridgeClientOptions>}
|
|
107
|
+
*/
|
|
108
|
+
export async function applyConfiguredAutoUpdate(
|
|
109
|
+
options = {},
|
|
110
|
+
env = process.env,
|
|
111
|
+
argv = process.argv
|
|
112
|
+
) {
|
|
113
|
+
if (options.updateNpmOnCompatibleVersion !== undefined) {
|
|
114
|
+
return options;
|
|
115
|
+
}
|
|
116
|
+
if (env[AUTO_UPDATE_ENV] === undefined && !isBrowserBridgeProcess(argv)) {
|
|
117
|
+
return options;
|
|
118
|
+
}
|
|
119
|
+
if (env.npm_command === 'exec' || (await getAutoUpdatePolicy(env)) !== 'compatible') {
|
|
120
|
+
return options;
|
|
121
|
+
}
|
|
122
|
+
const transport = options.socketPath ? null : (options.transport ?? getBridgeTransport(env));
|
|
123
|
+
if (transport?.type === 'tcp' && !isLoopbackHost(transport.host)) {
|
|
124
|
+
return options;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...options,
|
|
128
|
+
checkProtocolOnConnect: true,
|
|
129
|
+
updateNpmOnCompatibleVersion: true,
|
|
130
|
+
exitProcessOnNpmUpdate: isMcpProcess(argv),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} host
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
function isLoopbackHost(host) {
|
|
139
|
+
const normalized = host
|
|
140
|
+
.trim()
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/^\[|\]$/gu, '');
|
|
143
|
+
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(normalized);
|
|
147
|
+
return Boolean(
|
|
148
|
+
match && match.slice(1).every((part) => Number(part) <= 255) && match[1] === '127'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} [argv=process.argv]
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function isMcpProcess(argv = process.argv) {
|
|
157
|
+
const entry = (argv[1] || '').replaceAll('\\', '/');
|
|
158
|
+
const entryName = entry.slice(entry.lastIndexOf('/') + 1).toLowerCase();
|
|
159
|
+
return (
|
|
160
|
+
(argv[2] === 'mcp' && argv[3] === 'serve') ||
|
|
161
|
+
(entry.includes('/mcp-server/') && entry.endsWith('/bin.js')) ||
|
|
162
|
+
entryName === 'bbx-mcp' ||
|
|
163
|
+
entryName === 'bbx-mcp.cmd'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Persisted CLI preferences apply to the shipped executables, not arbitrary
|
|
169
|
+
* programmatic BridgeClient consumers that happen to share the same user home.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} [argv=process.argv]
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function isBrowserBridgeProcess(argv = process.argv) {
|
|
175
|
+
if (isMcpProcess(argv)) return true;
|
|
176
|
+
const entry = (argv[1] || '').replaceAll('\\', '/');
|
|
177
|
+
const entryName = entry.slice(entry.lastIndexOf('/') + 1).toLowerCase();
|
|
178
|
+
return (
|
|
179
|
+
(entry.includes('/agent-client/') && entry.endsWith('/cli.js')) ||
|
|
180
|
+
entryName === 'bbx' ||
|
|
181
|
+
entryName === 'bbx.cmd'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {unknown} error
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
function isMissingFileError(error) {
|
|
190
|
+
return Boolean(
|
|
191
|
+
error &&
|
|
192
|
+
typeof error === 'object' &&
|
|
193
|
+
/** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = '@browserbridge/bbx';
|
|
11
|
+
const PACKAGE_ROOT = path.dirname(fileURLToPath(new URL('../../../package.json', import.meta.url)));
|
|
12
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
13
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 120_000;
|
|
14
|
+
const UPDATE_LOCK_PORT_START = 49_152;
|
|
15
|
+
const UPDATE_LOCK_PORT_COUNT = 16_384;
|
|
16
|
+
const UPDATE_LOCK_PORT_ATTEMPTS = 32;
|
|
17
|
+
|
|
18
|
+
/** @typedef {import('./types.js').NpmUpdateResult} NpmUpdateResult */
|
|
19
|
+
|
|
20
|
+
export class NpmPackageUpdatedError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} version
|
|
23
|
+
*/
|
|
24
|
+
constructor(version) {
|
|
25
|
+
super(`Browser Bridge updated to ${version}; restarting to load the new version.`);
|
|
26
|
+
this.name = 'NpmPackageUpdatedError';
|
|
27
|
+
this.code = 'BBX_NPM_UPDATED';
|
|
28
|
+
this.version = version;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} value
|
|
34
|
+
* @returns {[number, number, number] | null}
|
|
35
|
+
*/
|
|
36
|
+
export function parseStableVersion(value) {
|
|
37
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(value);
|
|
38
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} left
|
|
43
|
+
* @param {string} right
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
export function comparePackageVersions(left, right) {
|
|
47
|
+
const leftParts = parseStableVersion(left);
|
|
48
|
+
const rightParts = parseStableVersion(right);
|
|
49
|
+
if (!leftParts || !rightParts) {
|
|
50
|
+
throw new Error(`Cannot compare invalid stable versions "${left}" and "${right}".`);
|
|
51
|
+
}
|
|
52
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
53
|
+
const delta = leftParts[index] - rightParts[index];
|
|
54
|
+
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} version
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function derivePackageProtocolVersion(version) {
|
|
64
|
+
const parsed = parseStableVersion(version);
|
|
65
|
+
return parsed ? `${parsed[0]}.${parsed[1]}` : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown[]} versions
|
|
70
|
+
* @param {readonly string[]} supportedProtocols
|
|
71
|
+
* @param {string} currentVersion
|
|
72
|
+
* @returns {string | null}
|
|
73
|
+
*/
|
|
74
|
+
export function selectCompatibleNpmVersion(versions, supportedProtocols, currentVersion) {
|
|
75
|
+
if (!parseStableVersion(currentVersion)) return null;
|
|
76
|
+
const supported = new Set(supportedProtocols.filter((version) => /^\d+\.\d+$/u.test(version)));
|
|
77
|
+
let selected = null;
|
|
78
|
+
for (const value of versions) {
|
|
79
|
+
if (typeof value !== 'string' || !parseStableVersion(value)) continue;
|
|
80
|
+
const protocolVersion = derivePackageProtocolVersion(value);
|
|
81
|
+
if (!protocolVersion || !supported.has(protocolVersion)) continue;
|
|
82
|
+
if (comparePackageVersions(value, currentVersion) <= 0) continue;
|
|
83
|
+
if (selected === null || comparePackageVersions(value, selected) > 0) selected = value;
|
|
84
|
+
}
|
|
85
|
+
return selected;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} packageRoot
|
|
90
|
+
* @returns {Promise<string | null>}
|
|
91
|
+
*/
|
|
92
|
+
async function readPackageVersion(packageRoot) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(
|
|
95
|
+
await fs.promises.readFile(path.join(packageRoot, 'package.json'), 'utf8')
|
|
96
|
+
);
|
|
97
|
+
return parsed && typeof parsed.version === 'string' ? parsed.version : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} args
|
|
105
|
+
* @param {{ env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options={}]
|
|
106
|
+
* @returns {Promise<string>}
|
|
107
|
+
*/
|
|
108
|
+
export async function runNpmCommand(args, options = {}) {
|
|
109
|
+
const env = options.env ?? process.env;
|
|
110
|
+
const npmExecPath = env.npm_execpath;
|
|
111
|
+
const useNode = Boolean(npmExecPath && /\.(?:c?js|mjs)$/u.test(npmExecPath));
|
|
112
|
+
const command = useNode
|
|
113
|
+
? process.execPath
|
|
114
|
+
: npmExecPath || (process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
115
|
+
const commandArgs = useNode && npmExecPath ? [npmExecPath, ...args] : args;
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
execFile(
|
|
119
|
+
command,
|
|
120
|
+
commandArgs,
|
|
121
|
+
{
|
|
122
|
+
env,
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
125
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
},
|
|
128
|
+
(error, stdout, stderr) => {
|
|
129
|
+
if (!error) {
|
|
130
|
+
resolve(stdout);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const detail = stderr.trim();
|
|
134
|
+
reject(
|
|
135
|
+
new Error(
|
|
136
|
+
`npm ${args.join(' ')} failed${detail ? `: ${detail.slice(0, 1000)}` : `: ${error.message}`}`
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bind an exclusive loopback port derived from the global npm package path.
|
|
146
|
+
* The OS releases the lock if the updater crashes, avoiding persistent stale
|
|
147
|
+
* lock files while preserving cross-process serialization.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} lockKey
|
|
150
|
+
* @param {number} [offset=0]
|
|
151
|
+
* @returns {number}
|
|
152
|
+
*/
|
|
153
|
+
export function getNpmUpdateLockPort(lockKey, offset = 0) {
|
|
154
|
+
const digest = createHash('sha256').update(lockKey).digest();
|
|
155
|
+
return (
|
|
156
|
+
UPDATE_LOCK_PORT_START +
|
|
157
|
+
((digest.readUInt32BE(0) + Math.max(0, Math.trunc(offset))) % UPDATE_LOCK_PORT_COUNT)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {number} port
|
|
163
|
+
* @returns {Promise<string | null>}
|
|
164
|
+
*/
|
|
165
|
+
async function readNpmUpdateLockIdentity(port) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
168
|
+
let settled = false;
|
|
169
|
+
let value = '';
|
|
170
|
+
/** @param {string | null} result */
|
|
171
|
+
const finish = (result) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
socket.destroy();
|
|
175
|
+
resolve(result);
|
|
176
|
+
};
|
|
177
|
+
socket.setEncoding('utf8');
|
|
178
|
+
socket.setTimeout(250, () => finish(null));
|
|
179
|
+
socket.on('data', (chunk) => {
|
|
180
|
+
value += chunk;
|
|
181
|
+
if (value.length > 256 || value.includes('\n')) finish(value.trim());
|
|
182
|
+
});
|
|
183
|
+
socket.on('end', () => finish(value.trim() || null));
|
|
184
|
+
socket.on('error', () => finish(null));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} lockKey
|
|
190
|
+
* @param {number} [timeoutMs=DEFAULT_LOCK_TIMEOUT_MS]
|
|
191
|
+
* @returns {Promise<() => Promise<void>>}
|
|
192
|
+
*/
|
|
193
|
+
export async function acquireNpmUpdateLock(lockKey, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
const identity = `bbx-npm-update:${createHash('sha256').update(lockKey).digest('hex')}`;
|
|
196
|
+
while (true) {
|
|
197
|
+
let activeUpdaterFound = false;
|
|
198
|
+
for (let offset = 0; offset < UPDATE_LOCK_PORT_ATTEMPTS; offset += 1) {
|
|
199
|
+
const port = getNpmUpdateLockPort(lockKey, offset);
|
|
200
|
+
const server = net.createServer((socket) => socket.end(`${identity}\n`));
|
|
201
|
+
const acquired = await new Promise((resolve, reject) => {
|
|
202
|
+
server.once('error', (error) => {
|
|
203
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'EADDRINUSE') {
|
|
204
|
+
resolve(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
reject(error);
|
|
208
|
+
});
|
|
209
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => resolve(true));
|
|
210
|
+
});
|
|
211
|
+
if (acquired) {
|
|
212
|
+
return async () => {
|
|
213
|
+
await new Promise((resolve) => server.close(resolve));
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if ((await readNpmUpdateLockIdentity(port)) === identity) {
|
|
217
|
+
activeUpdaterFound = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
activeUpdaterFound
|
|
224
|
+
? 'Timed out waiting for another Browser Bridge npm update to finish.'
|
|
225
|
+
: 'Could not reserve a local Browser Bridge npm update lock.'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {{
|
|
234
|
+
* extensionVersion: string,
|
|
235
|
+
* supportedVersions: readonly string[],
|
|
236
|
+
* env?: NodeJS.ProcessEnv,
|
|
237
|
+
* packageRoot?: string,
|
|
238
|
+
* lockPath?: string,
|
|
239
|
+
* runNpmFn?: typeof runNpmCommand,
|
|
240
|
+
* }} options
|
|
241
|
+
* @returns {Promise<NpmUpdateResult>}
|
|
242
|
+
*/
|
|
243
|
+
export async function updateCompatibleNpmPackage(options) {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
|
|
246
|
+
const runNpmFn = options.runNpmFn ?? runNpmCommand;
|
|
247
|
+
const extensionProtocol = derivePackageProtocolVersion(options.extensionVersion);
|
|
248
|
+
if (!extensionProtocol || !options.supportedVersions.includes(extensionProtocol)) {
|
|
249
|
+
return { updated: false, reason: 'invalid_extension_version' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const globalRoot = (await runNpmFn(['root', '--global'], { env })).trim();
|
|
253
|
+
const expectedRoot = path.join(globalRoot, '@browserbridge', 'bbx');
|
|
254
|
+
const [realPackageRoot, realExpectedRoot, expectedStat] = await Promise.all([
|
|
255
|
+
fs.promises.realpath(packageRoot).catch(() => ''),
|
|
256
|
+
fs.promises.realpath(expectedRoot).catch(() => ''),
|
|
257
|
+
fs.promises.lstat(expectedRoot).catch(() => null),
|
|
258
|
+
]);
|
|
259
|
+
if (!realPackageRoot || realPackageRoot !== realExpectedRoot || expectedStat?.isSymbolicLink()) {
|
|
260
|
+
return { updated: false, reason: 'not_global_install' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lockKey = options.lockPath ?? realExpectedRoot;
|
|
264
|
+
const releaseLock = await acquireNpmUpdateLock(lockKey);
|
|
265
|
+
try {
|
|
266
|
+
const currentVersion = await readPackageVersion(packageRoot);
|
|
267
|
+
if (!currentVersion || !parseStableVersion(currentVersion)) {
|
|
268
|
+
return { updated: false, reason: 'invalid_installed_version' };
|
|
269
|
+
}
|
|
270
|
+
if (comparePackageVersions(options.extensionVersion, currentVersion) <= 0) {
|
|
271
|
+
return { updated: false, reason: 'extension_not_newer', previousVersion: currentVersion };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const versionsRaw = await runNpmFn(['view', PACKAGE_NAME, 'versions', '--json'], { env });
|
|
275
|
+
const parsedVersions = JSON.parse(versionsRaw);
|
|
276
|
+
const versions = Array.isArray(parsedVersions) ? parsedVersions : [parsedVersions];
|
|
277
|
+
const targetVersion = selectCompatibleNpmVersion(
|
|
278
|
+
versions,
|
|
279
|
+
options.supportedVersions,
|
|
280
|
+
currentVersion
|
|
281
|
+
);
|
|
282
|
+
if (!targetVersion) {
|
|
283
|
+
return { updated: false, reason: 'no_compatible_update', previousVersion: currentVersion };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await runNpmFn(
|
|
287
|
+
['install', '--global', '--no-audit', '--no-fund', '--', `${PACKAGE_NAME}@${targetVersion}`],
|
|
288
|
+
{ env }
|
|
289
|
+
);
|
|
290
|
+
const installedVersion = await readPackageVersion(packageRoot);
|
|
291
|
+
if (installedVersion !== targetVersion) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`npm reported success, but Browser Bridge ${targetVersion} was not installed at ${packageRoot}.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
updated: true,
|
|
298
|
+
reason: 'updated',
|
|
299
|
+
previousVersion: currentVersion,
|
|
300
|
+
version: targetVersion,
|
|
301
|
+
};
|
|
302
|
+
} finally {
|
|
303
|
+
await releaseLock();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -7,6 +7,7 @@ import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/co
|
|
|
7
7
|
import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
|
|
8
8
|
import { atomicWriteFile } from './atomic-write.js';
|
|
9
9
|
import { BridgeClient } from './client.js';
|
|
10
|
+
import { applyConfiguredAutoUpdate } from './config.js';
|
|
10
11
|
|
|
11
12
|
const REMOTES_FILENAME = 'remotes.json';
|
|
12
13
|
const LOCAL_DESTINATION_ID = 'local';
|
|
@@ -287,12 +288,12 @@ function validateAuthToken(token) {
|
|
|
287
288
|
|
|
288
289
|
/**
|
|
289
290
|
* @param {string | null | undefined} destinationId
|
|
290
|
-
* @param {
|
|
291
|
+
* @param {import('./types.js').BridgeClientOptions} [options={}]
|
|
291
292
|
* @returns {Promise<BridgeClient>}
|
|
292
293
|
*/
|
|
293
294
|
export async function createBridgeClientForDestination(destinationId, options = {}) {
|
|
294
295
|
if (!destinationId || destinationId === LOCAL_DESTINATION_ID) {
|
|
295
|
-
return new BridgeClient(options);
|
|
296
|
+
return new BridgeClient(await applyConfiguredAutoUpdate(options));
|
|
296
297
|
}
|
|
297
298
|
const config = await readRemoteConfig();
|
|
298
299
|
const remote = config.remotes.find((entry) => entry.id === destinationId);
|
|
@@ -304,5 +305,6 @@ export async function createBridgeClientForDestination(destinationId, options =
|
|
|
304
305
|
transport: createTcpBridgeTransport(remote.port, remote.host),
|
|
305
306
|
authToken: remote.token,
|
|
306
307
|
restartDaemonOnVersionMismatch: false,
|
|
308
|
+
updateNpmOnCompatibleVersion: false,
|
|
307
309
|
});
|
|
308
310
|
}
|
|
@@ -28,9 +28,14 @@ import {
|
|
|
28
28
|
getBridgeOperationTimeoutMs,
|
|
29
29
|
getProtocolVersion,
|
|
30
30
|
MAX_CLIENT_REQUEST_TIMEOUT_MS,
|
|
31
|
+
normalizeRecoveryTelemetrySummary,
|
|
32
|
+
RECOVERY_EVENT_KINDS,
|
|
33
|
+
sanitizeIncidentalPath,
|
|
34
|
+
sanitizeIncidentalText,
|
|
31
35
|
} from '../../protocol/src/index.js';
|
|
32
36
|
import { methodNeedsTab } from './cli-helpers.js';
|
|
33
37
|
import { BridgeClient } from './client.js';
|
|
38
|
+
import { applyConfiguredAutoUpdate } from './config.js';
|
|
34
39
|
import { readRemoteConfig } from './remotes.js';
|
|
35
40
|
import { collectSetupStatus } from './setup-status.js';
|
|
36
41
|
|
|
@@ -101,7 +106,7 @@ export async function ensureClientConnected(client) {
|
|
|
101
106
|
* @param {BridgeClient} client
|
|
102
107
|
* @param {BridgeMethod} method
|
|
103
108
|
* @param {Record<string, unknown>} [params={}]
|
|
104
|
-
* @param {{ tabId?: number | null, source?: BridgeRequestSource, tokenBudget?: number | null }} [options]
|
|
109
|
+
* @param {{ tabId?: number | null, source?: BridgeRequestSource, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
|
|
105
110
|
* @returns {Promise<BridgeResponse>}
|
|
106
111
|
*/
|
|
107
112
|
export async function requestBridge(client, method, params = {}, options = {}) {
|
|
@@ -122,7 +127,7 @@ export async function requestBridge(client, method, params = {}, options = {}) {
|
|
|
122
127
|
method,
|
|
123
128
|
params,
|
|
124
129
|
tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
|
|
125
|
-
meta: withRequestMeta(options.source, options.tokenBudget),
|
|
130
|
+
meta: withRequestMeta(options.source, options.tokenBudget, options.automaticRetry),
|
|
126
131
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
127
132
|
});
|
|
128
133
|
}
|
|
@@ -164,9 +169,10 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
|
|
|
164
169
|
/**
|
|
165
170
|
* @param {BridgeRequestSource | undefined} source
|
|
166
171
|
* @param {number | null | undefined} tokenBudget
|
|
172
|
+
* @param {'mcp_second_attempt' | undefined} automaticRetry
|
|
167
173
|
* @returns {BridgeMeta}
|
|
168
174
|
*/
|
|
169
|
-
function withRequestMeta(source, tokenBudget) {
|
|
175
|
+
function withRequestMeta(source, tokenBudget, automaticRetry) {
|
|
170
176
|
/** @type {BridgeMeta} */
|
|
171
177
|
const meta = {};
|
|
172
178
|
if (source) {
|
|
@@ -175,6 +181,9 @@ function withRequestMeta(source, tokenBudget) {
|
|
|
175
181
|
if (typeof tokenBudget === 'number' && Number.isFinite(tokenBudget)) {
|
|
176
182
|
meta.token_budget = tokenBudget;
|
|
177
183
|
}
|
|
184
|
+
if (automaticRetry === 'mcp_second_attempt') {
|
|
185
|
+
meta.automatic_retry = { attempt: 2, reason: 'retryable_error' };
|
|
186
|
+
}
|
|
178
187
|
return meta;
|
|
179
188
|
}
|
|
180
189
|
|
|
@@ -185,7 +194,7 @@ function withRequestMeta(source, tokenBudget) {
|
|
|
185
194
|
* @returns {Promise<T>}
|
|
186
195
|
*/
|
|
187
196
|
export async function withBridgeClient(callback, options) {
|
|
188
|
-
const client = new BridgeClient(options);
|
|
197
|
+
const client = new BridgeClient(await applyConfiguredAutoUpdate(options));
|
|
189
198
|
await ensureClientConnected(client);
|
|
190
199
|
try {
|
|
191
200
|
return await callback(client);
|
|
@@ -661,6 +670,30 @@ function summarizeMetrics(value) {
|
|
|
661
670
|
requestsProcessed: boundedCount(metrics.requestsProcessed),
|
|
662
671
|
requestsFailed: boundedCount(metrics.requestsFailed),
|
|
663
672
|
avgResponseTimeMs: boundedCount(metrics.avgResponseTimeMs),
|
|
673
|
+
recovery: normalizeRecoveryTelemetrySummary(metrics.recovery, 'daemon'),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* @param {Record<string, unknown> | null} health
|
|
679
|
+
* @param {import('./types.js').DoctorDaemonMetrics | null} metrics
|
|
680
|
+
* @returns {import('./types.js').DoctorRecoveryDiagnostics}
|
|
681
|
+
*/
|
|
682
|
+
function summarizeRecovery(health, metrics) {
|
|
683
|
+
const recovery = asRecord(health?.recovery);
|
|
684
|
+
const daemon = metrics?.recovery ?? normalizeRecoveryTelemetrySummary(recovery?.daemon, 'daemon');
|
|
685
|
+
const routedExtension = normalizeRecoveryTelemetrySummary(
|
|
686
|
+
recovery?.routedExtension,
|
|
687
|
+
'routedExtension'
|
|
688
|
+
);
|
|
689
|
+
return {
|
|
690
|
+
daemon,
|
|
691
|
+
routedExtension,
|
|
692
|
+
activeLoops: RECOVERY_EVENT_KINDS.filter(
|
|
693
|
+
(kind) =>
|
|
694
|
+
daemon?.events[kind].activeLoop === true ||
|
|
695
|
+
routedExtension?.events[kind].activeLoop === true
|
|
696
|
+
),
|
|
664
697
|
};
|
|
665
698
|
}
|
|
666
699
|
|
|
@@ -947,6 +980,7 @@ export async function getDoctorReport(options = {}) {
|
|
|
947
980
|
},
|
|
948
981
|
protocol: summarizeProtocol(null, false),
|
|
949
982
|
debugger: summarizeDebugger(null),
|
|
983
|
+
recovery: summarizeRecovery(null, null),
|
|
950
984
|
metrics: null,
|
|
951
985
|
recentEvents: [],
|
|
952
986
|
recentCauses: [],
|
|
@@ -1105,6 +1139,10 @@ export async function getDoctorReport(options = {}) {
|
|
|
1105
1139
|
),
|
|
1106
1140
|
];
|
|
1107
1141
|
report.debugger = summarizeDebugger(report.healthAvailable ? resolvedHealth : null);
|
|
1142
|
+
report.recovery = summarizeRecovery(
|
|
1143
|
+
report.healthAvailable ? resolvedHealth : null,
|
|
1144
|
+
report.metrics
|
|
1145
|
+
);
|
|
1108
1146
|
if (report.debugger.recentReason && !report.recentCauses.includes(report.debugger.recentReason)) {
|
|
1109
1147
|
report.recentCauses.push(report.debugger.recentReason);
|
|
1110
1148
|
}
|
|
@@ -1243,5 +1281,59 @@ export async function getDoctorReport(options = {}) {
|
|
|
1243
1281
|
);
|
|
1244
1282
|
}
|
|
1245
1283
|
|
|
1246
|
-
|
|
1284
|
+
for (const loop of report.recovery.activeLoops) {
|
|
1285
|
+
const issue = `${loop}_loop`;
|
|
1286
|
+
if (!report.issues.includes(issue)) report.issues.push(issue);
|
|
1287
|
+
if (loop === 'stale_ref_recovery') {
|
|
1288
|
+
report.nextSteps.push(
|
|
1289
|
+
'Stale-reference recovery is repeatedly failing. Re-query the target for a fresh elementRef and retry once without reusing old refs.'
|
|
1290
|
+
);
|
|
1291
|
+
} else if (
|
|
1292
|
+
loop === 'debugger_reattach' &&
|
|
1293
|
+
report.debugger.state !== 'conflict' &&
|
|
1294
|
+
!report.recentCauses.includes('debugger_conflict')
|
|
1295
|
+
) {
|
|
1296
|
+
report.nextSteps.push(
|
|
1297
|
+
'Debugger reattachment is repeatedly failing. Keep the tab open, close competing debuggers, and retry one debugger-backed operation.'
|
|
1298
|
+
);
|
|
1299
|
+
} else if (loop === 'content_script_reinjection') {
|
|
1300
|
+
report.nextSteps.push(
|
|
1301
|
+
'Content-script reinjection is repeatedly failing. Reload the Browser Bridge extension and target page, then enable the window again.'
|
|
1302
|
+
);
|
|
1303
|
+
} else if (loop === 'native_host_reconnect' && !authenticationFailed) {
|
|
1304
|
+
report.nextSteps.push(
|
|
1305
|
+
'Native-host reconnects are repeatedly failing. Run `bbx restart`; for remote TCP proxy use, verify the endpoint and rotate stale credentials with `bbx proxy enable --rotate-token`.'
|
|
1306
|
+
);
|
|
1307
|
+
} else if (loop === 'automatic_mcp_retry') {
|
|
1308
|
+
report.nextSteps.push(
|
|
1309
|
+
'Automatic MCP retries are repeatedly failing. Stop the retry loop, resolve the reported bridge readiness issue, then retry the original read once.'
|
|
1310
|
+
);
|
|
1311
|
+
} else if (loop === 'request_outcome') {
|
|
1312
|
+
report.nextSteps.push(
|
|
1313
|
+
'Browser requests are repeatedly failing. Inspect the first typed bridge error, narrow the request or target as directed, and retry once after correcting that specific failure.'
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return {
|
|
1319
|
+
...report,
|
|
1320
|
+
manifestPath: sanitizeDoctorPath(report.manifestPath),
|
|
1321
|
+
daemonLogPath: sanitizeDoctorPath(report.daemonLogPath),
|
|
1322
|
+
unwritableBridgePaths: report.unwritableBridgePaths.map(sanitizeDoctorPath),
|
|
1323
|
+
nativeHostManifestIssues: report.nativeHostManifestIssues.map((issue) => ({
|
|
1324
|
+
...issue,
|
|
1325
|
+
manifestPath: sanitizeDoctorPath(issue.manifestPath),
|
|
1326
|
+
message: sanitizeIncidentalText(issue.message),
|
|
1327
|
+
})),
|
|
1328
|
+
browserManifests: report.browserManifests.map((browser) => ({
|
|
1329
|
+
...browser,
|
|
1330
|
+
manifestPath: sanitizeDoctorPath(browser.manifestPath),
|
|
1331
|
+
})),
|
|
1332
|
+
nextSteps: report.nextSteps.map((step) => sanitizeIncidentalText(step)),
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** @param {string} value */
|
|
1337
|
+
function sanitizeDoctorPath(value) {
|
|
1338
|
+
return value ? sanitizeIncidentalPath(value) : '';
|
|
1247
1339
|
}
|