@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.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/CHANGELOG.md +165 -0
- package/LICENSE +27 -0
- package/NOTICE +34 -0
- package/README.md +452 -2
- package/README.zh-CN.md +328 -0
- package/bin/browser-use-mcp.mjs +83 -0
- package/docs/architecture.md +210 -0
- package/docs/clients.md +425 -0
- package/docs/config.md +260 -0
- package/lib/cli.js +146 -0
- package/lib/config.js +341 -0
- package/lib/doctor.js +62 -0
- package/lib/engine.js +717 -0
- package/lib/http.js +222 -0
- package/lib/jev.js +105 -0
- package/lib/log.js +32 -0
- package/lib/provision.js +585 -0
- package/lib/python.js +470 -0
- package/lib/render.js +75 -0
- package/lib/server.js +193 -0
- package/lib/session.js +484 -0
- package/lib/sidecar.js +472 -0
- package/lib/tools.js +352 -0
- package/package.json +69 -4
- package/sidecar/bridge.py +982 -0
- package/vendor/jev-ultrafast.json +6 -0
- package/vendor/jev_ultrafast-0.1.1-py3-none-any.whl +0 -0
- package/vendor/requirements.txt +74 -0
package/lib/engine.js
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding, checking, and (when this server owns the environment) installing the engine.
|
|
3
|
+
*
|
|
4
|
+
* The sidecar is Python and the engine it drives is a Python package, so before the first browser
|
|
5
|
+
* call this server has to answer: which interpreter imports `jev_ultrafast`, which browser can be
|
|
6
|
+
* driven, and what should a person do when either is missing. `inspectEngine` only looks — it never
|
|
7
|
+
* installs — and is what the doctor tool, the server's startup log, and a browser tool's refusal
|
|
8
|
+
* share. `ensureEngine` is the one that acts: when no interpreter imports the engine and the
|
|
9
|
+
* managed environment is the one in use, it installs the engine there first.
|
|
10
|
+
*
|
|
11
|
+
* Two rules shape the interpreter list. A configured `--python` is the answer or the failure: the
|
|
12
|
+
* probe tries it alone, because falling back to another interpreter would silently ignore the
|
|
13
|
+
* configuration. Otherwise the managed environment (which this server builds, see `./provision.js`)
|
|
14
|
+
* is tried first, then the Pythons a machine already has. The list is probed in order, and a cached
|
|
15
|
+
* success is what keeps a doctor call from spawning a Python per HTTP request.
|
|
16
|
+
*
|
|
17
|
+
* @module browser-use-mcp/engine
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { get as httpGet } from 'node:http'
|
|
23
|
+
import { get as httpsGet } from 'node:https'
|
|
24
|
+
import { connect } from 'node:net'
|
|
25
|
+
import { delimiter, isAbsolute, join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { basePythons, environmentPython } from './python.js'
|
|
28
|
+
import {
|
|
29
|
+
PACKAGE_ROOT,
|
|
30
|
+
bundledEngine,
|
|
31
|
+
duration,
|
|
32
|
+
environmentRecord,
|
|
33
|
+
installEngine,
|
|
34
|
+
installHint,
|
|
35
|
+
installStatus,
|
|
36
|
+
ownEnvironment,
|
|
37
|
+
} from './provision.js'
|
|
38
|
+
|
|
39
|
+
/** This package's own root: the sidecar ships here and the managed environment lives here. */
|
|
40
|
+
export { PACKAGE_ROOT }
|
|
41
|
+
|
|
42
|
+
/** The sidecar that is started, and the one file that has to exist for it to exist. */
|
|
43
|
+
export const SIDECAR = join(PACKAGE_ROOT, 'sidecar', 'bridge.py')
|
|
44
|
+
|
|
45
|
+
/** The bundled engine wheel and its hash-pinned requirements. */
|
|
46
|
+
export const VENDOR = join(PACKAGE_ROOT, 'vendor')
|
|
47
|
+
|
|
48
|
+
/** How the environment this server builds for itself is named in reports. */
|
|
49
|
+
const OWN = 'managed environment'
|
|
50
|
+
|
|
51
|
+
/** How long one engine probe may take: importing the engine imports Browser Harness too. */
|
|
52
|
+
const PROBE_TIMEOUT_MS = 60000
|
|
53
|
+
|
|
54
|
+
/** How long one attach check may take before the target counts as not answering. */
|
|
55
|
+
const ATTACH_TIMEOUT_MS = 3000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the probe asks the interpreter to report; importing the engine imports Browser Harness too.
|
|
59
|
+
*
|
|
60
|
+
* `importlib.metadata` is asked for the distribution versions instead of a `__version__` attribute
|
|
61
|
+
* because the wheel name is what was installed, and a missing version is reported as `unknown`
|
|
62
|
+
* rather than making a working engine look broken.
|
|
63
|
+
*/
|
|
64
|
+
const PROBE = [
|
|
65
|
+
'-c',
|
|
66
|
+
[
|
|
67
|
+
'import importlib, importlib.metadata as metadata, json, sys',
|
|
68
|
+
'def installed(name):',
|
|
69
|
+
' try:',
|
|
70
|
+
' return metadata.version(name)',
|
|
71
|
+
' except Exception:',
|
|
72
|
+
' return "unknown"',
|
|
73
|
+
'engine = importlib.import_module("jev_ultrafast")',
|
|
74
|
+
'harness = importlib.import_module("browser_harness")',
|
|
75
|
+
'json.dump({',
|
|
76
|
+
' "python": ".".join(str(part) for part in sys.version_info[:3]),',
|
|
77
|
+
' "engine": installed("jev-ultrafast"),',
|
|
78
|
+
' "enginePath": list(engine.__path__)[0],',
|
|
79
|
+
' "browserHarness": installed("browser-harness"),',
|
|
80
|
+
'}, sys.stdout)',
|
|
81
|
+
].join('\n'),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Interpreter list -> the probe answer that found the engine there.
|
|
86
|
+
*
|
|
87
|
+
* Only a success is remembered: an engine installed a moment later (by this server, or by a person
|
|
88
|
+
* following the doctor's fix) has to be found by the next call, while a machine that answered once
|
|
89
|
+
* does not answer again on every browser call.
|
|
90
|
+
*/
|
|
91
|
+
const found = new Map()
|
|
92
|
+
|
|
93
|
+
/** The candidates' identity for the probe cache; two lists with the same commands are one list. */
|
|
94
|
+
function cacheKey(candidates) {
|
|
95
|
+
return candidates.map(item => [item.command, ...item.prefix].join(' ')).join('\u0000')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether this server may install the engine itself: only when no explicit interpreter was named.
|
|
100
|
+
* There is no external project concept here — the engine always comes from the bundled wheel — so
|
|
101
|
+
* an explicit `--python` is the one thing that makes the environment someone else's to manage.
|
|
102
|
+
* @param {object} [config] - resolved server configuration.
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function installable(config = {}) {
|
|
106
|
+
return String(config.pythonPath ?? '').trim() === ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every interpreter worth trying, best first, each with the reason it is on the list.
|
|
111
|
+
*
|
|
112
|
+
* A configured `pythonPath` is the answer or the failure, so it is the only candidate. Otherwise the
|
|
113
|
+
* environment this server builds comes first: when it exists it is the environment the sidecar
|
|
114
|
+
* should run in, and when it does not the probe's failure is what tells `ensureEngine` to build it.
|
|
115
|
+
* The Pythons from `lib/python.js` follow, for a machine where the managed environment is not there
|
|
116
|
+
* yet or was built for another purpose. uv is skipped here on purpose: it builds environments, it is
|
|
117
|
+
* not an interpreter, and probing `uv -c` would only add a failure that says nothing.
|
|
118
|
+
* @param {object} [config] - resolved server configuration.
|
|
119
|
+
* @returns {{command: string, prefix: string[], cwd: string, source: string}[]}
|
|
120
|
+
*/
|
|
121
|
+
export function interpreterCandidates(config = {}) {
|
|
122
|
+
const pythonPath = String(config.pythonPath ?? '').trim()
|
|
123
|
+
if (pythonPath !== '') {
|
|
124
|
+
return [{ command: pythonPath, prefix: [], cwd: PACKAGE_ROOT, source: 'pythonPath' }]
|
|
125
|
+
}
|
|
126
|
+
const candidates = [{
|
|
127
|
+
command: environmentPython(ownEnvironment(config)),
|
|
128
|
+
prefix: [],
|
|
129
|
+
cwd: PACKAGE_ROOT,
|
|
130
|
+
source: OWN,
|
|
131
|
+
}]
|
|
132
|
+
const add = (command, prefix, source) => {
|
|
133
|
+
if (candidates.some(item => item.command === command && item.prefix.join(' ') === prefix.join(' '))) return
|
|
134
|
+
candidates.push({ command, prefix, cwd: PACKAGE_ROOT, source })
|
|
135
|
+
}
|
|
136
|
+
for (const candidate of basePythons(config)) {
|
|
137
|
+
if (candidate.source === 'uv') continue
|
|
138
|
+
add(candidate.command, candidate.prefix, candidate.source)
|
|
139
|
+
}
|
|
140
|
+
return candidates
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The interpreter that runs the sidecar when nothing has probed one yet.
|
|
145
|
+
*
|
|
146
|
+
* Only for callers that must name an interpreter before a report exists; the normal path uses
|
|
147
|
+
* `report.interpreter`, which is the one the probe actually accepted.
|
|
148
|
+
* @param {object} [config] - resolved server configuration.
|
|
149
|
+
* @returns {{command: string, prefix: string[], cwd: string, source: string}}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveInterpreter(config = {}) {
|
|
152
|
+
return interpreterCandidates(config)[0]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run one probe program on an interpreter.
|
|
157
|
+
*
|
|
158
|
+
* A missing absolute interpreter is reported without spawning it: the managed environment not
|
|
159
|
+
* existing yet is the ordinary first-run case, not an error worth a process. Everything else is
|
|
160
|
+
* spawned and every way it can go wrong — cannot start, timeout, nonzero exit, unreadable answer —
|
|
161
|
+
* becomes `{failure}` so the doctor can name each interpreter it tried.
|
|
162
|
+
* @param {{command: string, prefix: string[], cwd: string, source: string}} interpreter
|
|
163
|
+
* @param {string[]} [program] - argv after the interpreter; the engine import probe by default.
|
|
164
|
+
* @returns {Promise<{report: object}|{failure: string}>}
|
|
165
|
+
*/
|
|
166
|
+
export function probe(interpreter, program = PROBE) {
|
|
167
|
+
if (interpreter === undefined || typeof interpreter.command !== 'string') {
|
|
168
|
+
return Promise.resolve({ failure: 'no interpreter was named' })
|
|
169
|
+
}
|
|
170
|
+
if (isAbsolute(interpreter.command) && !existsSync(interpreter.command)) {
|
|
171
|
+
return Promise.resolve({
|
|
172
|
+
failure: interpreter.source === OWN ? 'not installed yet' : 'does not exist',
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return new Promise(resolve => {
|
|
176
|
+
let child
|
|
177
|
+
try {
|
|
178
|
+
child = spawn(interpreter.command, [...interpreter.prefix, ...program], {
|
|
179
|
+
cwd: interpreter.cwd,
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
})
|
|
183
|
+
} catch (error) {
|
|
184
|
+
resolve({ failure: `${interpreter.command} could not be started: ${error.message}` })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
let out = ''
|
|
188
|
+
let err = ''
|
|
189
|
+
let settled = false
|
|
190
|
+
const settle = value => {
|
|
191
|
+
if (settled) return
|
|
192
|
+
settled = true
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
resolve(value)
|
|
195
|
+
}
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
child.kill('SIGKILL')
|
|
198
|
+
settle({ failure: `the probe did not answer within ${Math.round(PROBE_TIMEOUT_MS / 1000)}s` })
|
|
199
|
+
}, PROBE_TIMEOUT_MS)
|
|
200
|
+
child.stdout.on('data', chunk => { out += String(chunk) })
|
|
201
|
+
child.stderr.on('data', chunk => { err += String(chunk) })
|
|
202
|
+
child.on('error', error => {
|
|
203
|
+
settle({ failure: `${interpreter.command} could not be started: ${error.message}` })
|
|
204
|
+
})
|
|
205
|
+
child.on('close', code => {
|
|
206
|
+
if (code !== 0) {
|
|
207
|
+
const detail = err.trim().split('\n').filter(Boolean).at(-1) ?? `exit status ${code}`
|
|
208
|
+
settle({ failure: detail })
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
let report
|
|
212
|
+
try {
|
|
213
|
+
report = JSON.parse(out)
|
|
214
|
+
} catch {
|
|
215
|
+
settle({ failure: `the probe answered something unreadable: ${out.slice(0, 120)}` })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (report === null || typeof report !== 'object') {
|
|
219
|
+
settle({ failure: `the probe answered something unexpected: ${out.slice(0, 120)}` })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
// The engine probe has a fixed shape; the attach probe answers a profile or a reason, so the
|
|
223
|
+
// shape check belongs to the program that was run, not to every program.
|
|
224
|
+
if (program === PROBE
|
|
225
|
+
&& (typeof report.python !== 'string' || typeof report.enginePath !== 'string')) {
|
|
226
|
+
settle({ failure: `the probe answered something unexpected: ${out.slice(0, 120)}` })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
settle({ report })
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Probe the candidates in order: the first interpreter that imports the engine wins. */
|
|
235
|
+
async function findEngine(candidates) {
|
|
236
|
+
const search = { interpreter: candidates[0], attempts: [] }
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
const result = await probe(candidate)
|
|
239
|
+
if (result.report !== undefined) {
|
|
240
|
+
search.interpreter = candidate
|
|
241
|
+
search.engine = result.report
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
search.attempts.push({ ...candidate, failure: result.failure })
|
|
245
|
+
}
|
|
246
|
+
return search
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The problem a missing engine is, and its fix, which depends on who owns the environment.
|
|
251
|
+
*
|
|
252
|
+
* Every branch answers with a code, a message that names what was tried, and a fix someone can act
|
|
253
|
+
* on: the install command for the managed environment, the pip command for a configured
|
|
254
|
+
* interpreter, or the state of an install that is already running.
|
|
255
|
+
*/
|
|
256
|
+
function missingEngine(config, report) {
|
|
257
|
+
const tried = report.attempts.length === 0
|
|
258
|
+
? 'no interpreter answered'
|
|
259
|
+
: report.attempts.map(item => `${item.command} (${item.source}): ${item.failure}`).join('\n ')
|
|
260
|
+
if (String(config.pythonPath ?? '').trim() !== '') {
|
|
261
|
+
const wheel = bundledEngine()
|
|
262
|
+
const install = wheel === undefined
|
|
263
|
+
? 'install the engine into that interpreter'
|
|
264
|
+
: `install the engine into that interpreter (uv pip install --python ${config.pythonPath} ${wheel.path})`
|
|
265
|
+
return [
|
|
266
|
+
'no_engine',
|
|
267
|
+
`the configured --python cannot import the engine:\n ${tried}`,
|
|
268
|
+
`${install}, or clear --python and this server installs an environment of its own`,
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
const install = report.install
|
|
272
|
+
if (install === undefined) {
|
|
273
|
+
return [
|
|
274
|
+
'no_engine',
|
|
275
|
+
`the browser engine is not installed yet in ${ownEnvironment(config)}`,
|
|
276
|
+
`the next browser call installs it; to install it now: ${installHint(config)}`,
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
if (install.state === 'installing') {
|
|
280
|
+
return [
|
|
281
|
+
'installing',
|
|
282
|
+
`the browser engine is being installed into ${install.environment} `
|
|
283
|
+
+ `(for ${duration(Date.now() - install.startedAt)}; ${install.step})`,
|
|
284
|
+
'wait: browser calls wait for this install, and browser_doctor shows how far it got',
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
if (install.state === 'failed') {
|
|
288
|
+
return install.code === 'no_python' || install.code === 'no_uv'
|
|
289
|
+
? [install.code, install.error, install.fix]
|
|
290
|
+
: ['install_failed', `installing the browser engine failed: ${install.error}`, install.fix]
|
|
291
|
+
}
|
|
292
|
+
return [
|
|
293
|
+
'install_failed',
|
|
294
|
+
`the browser engine was installed into ${install.environment}, and it still does not import: `
|
|
295
|
+
+ `${report.attempts.at(-1)?.failure ?? 'no answer'}`,
|
|
296
|
+
`delete ${install.environment}, then retry: ${installHint(config)}`,
|
|
297
|
+
]
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The browser `attach` mode drives: the configured endpoint, or the one you browse with. */
|
|
301
|
+
function attachTarget(config) {
|
|
302
|
+
return config.cdpEndpoint
|
|
303
|
+
? config.cdpEndpoint
|
|
304
|
+
: 'your running Chrome (found through chrome://inspect remote debugging)'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** What to do about an attach target that does not answer, whatever the reason. */
|
|
308
|
+
const ATTACH_ESCAPE = 'start that browser with remote debugging on (chrome://inspect/#remote-debugging), '
|
|
309
|
+
+ 'correct --cdp-endpoint, or set --mode launch and this server starts its own browser'
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The address `/json/version` answers at, for an endpoint that may already name it.
|
|
313
|
+
*
|
|
314
|
+
* Chrome resolves an HTTP DevTools address through this route, which keeps an HTTP endpoint working
|
|
315
|
+
* after the browser restarts, while a ws URL carries an id Chrome mints afresh on every start.
|
|
316
|
+
*/
|
|
317
|
+
function versionURL(endpoint) {
|
|
318
|
+
const url = new URL(endpoint)
|
|
319
|
+
const path = url.pathname.replace(/\/+$/u, '')
|
|
320
|
+
if (!path.endsWith('/json/version')) url.pathname = `${path}/json/version`
|
|
321
|
+
url.search = ''
|
|
322
|
+
url.hash = ''
|
|
323
|
+
return url
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Ask one URL for its version document, over a socket this request owns and closes. */
|
|
327
|
+
function fetchVersion(url) {
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const request = (url.protocol === 'https:' ? httpsGet : httpGet)(url, { agent: false }, response => {
|
|
330
|
+
if (response.statusCode !== 200) {
|
|
331
|
+
response.resume()
|
|
332
|
+
reject(new Error(`HTTP ${response.statusCode}`))
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
let body = ''
|
|
336
|
+
response.setEncoding('utf8')
|
|
337
|
+
response.on('data', chunk => { body += chunk })
|
|
338
|
+
response.on('end', () => {
|
|
339
|
+
try {
|
|
340
|
+
resolve(JSON.parse(body))
|
|
341
|
+
} catch {
|
|
342
|
+
reject(new Error('the answer was not the JSON /json/version returns'))
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
request.setTimeout(ATTACH_TIMEOUT_MS,
|
|
347
|
+
() => request.destroy(new Error(`no answer within ${ATTACH_TIMEOUT_MS / 1000}s`)))
|
|
348
|
+
request.on('error', reject)
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Whether anything is listening at one host and port, which is all a ws endpoint can be asked. */
|
|
353
|
+
function listening(host, port) {
|
|
354
|
+
return new Promise(resolve => {
|
|
355
|
+
const socket = connect({ host, port })
|
|
356
|
+
const settle = reachable => {
|
|
357
|
+
socket.removeAllListeners()
|
|
358
|
+
socket.destroy()
|
|
359
|
+
resolve(reachable)
|
|
360
|
+
}
|
|
361
|
+
socket.setTimeout(ATTACH_TIMEOUT_MS, () => settle(false))
|
|
362
|
+
socket.once('connect', () => settle(true))
|
|
363
|
+
socket.once('error', () => settle(false))
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether the configured DevTools endpoint answers, and what it says it is.
|
|
369
|
+
*
|
|
370
|
+
* An HTTP endpoint is asked the way the engine asks it — `GET /json/version` — so this check and the
|
|
371
|
+
* first browser call get the same answer. A ws endpoint is only asked whether anything is listening:
|
|
372
|
+
* it names one browser id, and a WebSocket handshake here would open a second CDP connection to
|
|
373
|
+
* answer a question this check does not need.
|
|
374
|
+
*/
|
|
375
|
+
async function checkEndpoint(endpoint) {
|
|
376
|
+
const url = new URL(endpoint)
|
|
377
|
+
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
|
378
|
+
const port = Number(url.port || (url.protocol === 'wss:' ? 443 : 80))
|
|
379
|
+
if (await listening(url.hostname, port)) {
|
|
380
|
+
return { checked: true, reachable: true, detail: `${endpoint} answers on ${url.host}` }
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
checked: true,
|
|
384
|
+
reachable: false,
|
|
385
|
+
detail: `${endpoint} \u2014 nothing is listening at ${url.host}`,
|
|
386
|
+
code: 'attach_unreachable',
|
|
387
|
+
message: `attach mode drives ${endpoint}, and nothing is listening at ${url.host}`,
|
|
388
|
+
fix: ATTACH_ESCAPE,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const asked = versionURL(endpoint)
|
|
392
|
+
try {
|
|
393
|
+
const answer = await fetchVersion(asked)
|
|
394
|
+
const browser = typeof answer?.Browser === 'string' && answer.Browser !== '' ? ` \u2014 ${answer.Browser}` : ''
|
|
395
|
+
return { checked: true, reachable: true, detail: `${endpoint} answers${browser}` }
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return {
|
|
398
|
+
checked: true,
|
|
399
|
+
reachable: false,
|
|
400
|
+
detail: `${endpoint} \u2014 ${asked.href} did not answer`,
|
|
401
|
+
code: 'attach_unreachable',
|
|
402
|
+
message: `attach mode drives ${endpoint}, and ${asked.href} did not answer (${error.message})`,
|
|
403
|
+
fix: ATTACH_ESCAPE,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* What the engine's own discovery answers, run on the interpreter the sidecar would use.
|
|
410
|
+
*
|
|
411
|
+
* `attach` without an endpoint drives the browser you browse with, which the engine finds through
|
|
412
|
+
* the `DevToolsActivePort` Chrome publishes into the profile it was started with. Asking the engine
|
|
413
|
+
* itself keeps this check and the first browser call from disagreeing about what is there; the probe
|
|
414
|
+
* only reads what is already published, it opens no page and does not wait for a prompt.
|
|
415
|
+
*/
|
|
416
|
+
const ATTACH_PROBE = [
|
|
417
|
+
'-c',
|
|
418
|
+
[
|
|
419
|
+
'import importlib.util, json, sys',
|
|
420
|
+
'spec = importlib.util.spec_from_file_location("browser_use_mcp_bridge", sys.argv[1])',
|
|
421
|
+
'bridge = importlib.util.module_from_spec(spec)',
|
|
422
|
+
'spec.loader.exec_module(bridge)',
|
|
423
|
+
'try:',
|
|
424
|
+
' json.dump({"profile": str(bridge.discover_local_browser())}, sys.stdout)',
|
|
425
|
+
'except bridge.BridgeError as error:',
|
|
426
|
+
' json.dump({"kind": error.kind, "message": str(error)}, sys.stdout)',
|
|
427
|
+
].join('\n'),
|
|
428
|
+
SIDECAR,
|
|
429
|
+
]
|
|
430
|
+
|
|
431
|
+
/** The attach probe's answer: the profile the engine found, or why it found none. */
|
|
432
|
+
async function probeAttachBrowser(interpreter) {
|
|
433
|
+
const result = await probe(interpreter, ATTACH_PROBE)
|
|
434
|
+
if (result.report !== undefined) return result.report
|
|
435
|
+
return { kind: 'probe_failed', message: result.failure }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether `attach` mode can reach the browser it is configured to drive.
|
|
440
|
+
*
|
|
441
|
+
* This asks the question the first browser call asks, before that call: a configured endpoint
|
|
442
|
+
* directly, and the browser you browse with through the engine that would find it. What it finds is
|
|
443
|
+
* what makes `ok` mean "the browser tools would work" in this mode too, instead of the server
|
|
444
|
+
* reporting a ready engine while the browser it was told to drive is not there. It never throws:
|
|
445
|
+
* a malformed endpoint or a probe that cannot run is an answer, not a crash.
|
|
446
|
+
* @param {object} [config] - resolved server configuration.
|
|
447
|
+
* @param {{command: string, prefix: string[], cwd: string, source: string}} [interpreter] - the
|
|
448
|
+
* interpreter that imports the engine, or `undefined` when none does.
|
|
449
|
+
* @returns {Promise<{target: string, checked: boolean, reachable?: boolean, detail: string,
|
|
450
|
+
* code?: string, message?: string, fix?: string}>}
|
|
451
|
+
*/
|
|
452
|
+
export async function inspectAttach(config = {}, interpreter) {
|
|
453
|
+
const target = attachTarget(config)
|
|
454
|
+
try {
|
|
455
|
+
if (config.cdpEndpoint) return { target, ...await checkEndpoint(config.cdpEndpoint) }
|
|
456
|
+
if (interpreter === undefined) {
|
|
457
|
+
return { target, checked: false, detail: `${target} \u2014 not checked: the engine is not installed yet` }
|
|
458
|
+
}
|
|
459
|
+
const answer = await probeAttachBrowser(interpreter)
|
|
460
|
+
if (answer.profile !== undefined) {
|
|
461
|
+
return { target, checked: true, reachable: true, detail: `${target} \u2014 profile ${answer.profile}` }
|
|
462
|
+
}
|
|
463
|
+
if (answer.kind === 'no_permission' || answer.kind === 'no_browser') {
|
|
464
|
+
return {
|
|
465
|
+
target,
|
|
466
|
+
checked: true,
|
|
467
|
+
reachable: false,
|
|
468
|
+
detail: `${target} \u2014 not found`,
|
|
469
|
+
// The engine's own codes and words, so the doctor and the first browser call agree.
|
|
470
|
+
code: answer.kind,
|
|
471
|
+
message: answer.message ?? `the engine found no running browser to attach to`,
|
|
472
|
+
fix: ATTACH_ESCAPE,
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
target,
|
|
477
|
+
checked: false,
|
|
478
|
+
reachable: false,
|
|
479
|
+
detail: `${target} \u2014 not checked: ${answer.message ?? 'the check could not run'}`,
|
|
480
|
+
code: 'attach_unchecked',
|
|
481
|
+
message: `attach mode drives ${target}, and the check could not run: `
|
|
482
|
+
+ `${answer.message ?? 'the engine answered nothing'}`,
|
|
483
|
+
fix: 'check the interpreter this report names, then call browser_doctor again',
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
const reason = error?.message ?? String(error)
|
|
487
|
+
return {
|
|
488
|
+
target,
|
|
489
|
+
checked: false,
|
|
490
|
+
reachable: false,
|
|
491
|
+
detail: `${target} \u2014 not checked: ${reason}`,
|
|
492
|
+
code: 'attach_unchecked',
|
|
493
|
+
message: `attach mode could not check ${target}: ${reason}`,
|
|
494
|
+
fix: ATTACH_ESCAPE,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Whether a configured value counts as set, without ever exposing it. */
|
|
500
|
+
function isConfigured(value) {
|
|
501
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* One line on how a browser goal would decide, without printing a credential.
|
|
506
|
+
*
|
|
507
|
+
* The doctor has to say whether the tools that spend model quota will work, and the answer is
|
|
508
|
+
* whether the key they need exists — never its value. The process environment is checked as well as
|
|
509
|
+
* the resolved config, because Jev's own variables are the documented way an MCP client supplies a
|
|
510
|
+
* key and a config that predates this code may not carry them.
|
|
511
|
+
*/
|
|
512
|
+
function goalModeText(config) {
|
|
513
|
+
const jev = config.jev ?? {}
|
|
514
|
+
const typesafe = jev.typesafe ?? {}
|
|
515
|
+
const textModel = jev.textModel ?? {}
|
|
516
|
+
if (jev.decider === 'chat') {
|
|
517
|
+
const configured = isConfigured(textModel.apiKey) || isConfigured(textModel.baseURL)
|
|
518
|
+
|| isConfigured(textModel.model) || isConfigured(process.env.TEXT_MODEL_API_KEY)
|
|
519
|
+
|| isConfigured(process.env.TEXT_MODEL)
|
|
520
|
+
return 'enabled: browser_goal and browser_act intents spend model quota; the text model decides '
|
|
521
|
+
+ `(jev.decider: chat), the chat text model is ${configured ? 'set' : 'unset'}`
|
|
522
|
+
}
|
|
523
|
+
const configured = isConfigured(typesafe.apiKey) || isConfigured(process.env.TYPESAFE_API_KEY)
|
|
524
|
+
return 'enabled: browser_goal and browser_act intents spend model quota; Jev decides, '
|
|
525
|
+
+ `TYPESAFE_API_KEY is ${configured ? 'set' : 'unset'}`
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Everything a person needs to know before the first browser tool call.
|
|
530
|
+
*
|
|
531
|
+
* This only looks: `ensureEngine` is what installs a missing engine. It never throws for an
|
|
532
|
+
* environment problem — every failure becomes a problem with a code, a message naming what was
|
|
533
|
+
* tried, and a fix someone can read.
|
|
534
|
+
* @param {object} [config] - resolved server configuration.
|
|
535
|
+
* @param {{fresh?: boolean}} [options] - `fresh` probes again instead of using the remembered
|
|
536
|
+
* answer, for a doctor call the user just asked for.
|
|
537
|
+
* @returns {Promise<object>} the engine report: `{ok, sidecar, interpreter, attempts, problems,
|
|
538
|
+
* engine?, install?, browser?, attach?, goalMode?}`.
|
|
539
|
+
*/
|
|
540
|
+
export async function inspectEngine(config = {}, options = {}) {
|
|
541
|
+
const candidates = interpreterCandidates(config)
|
|
542
|
+
const key = cacheKey(candidates)
|
|
543
|
+
let search = options.fresh === true ? undefined : found.get(key)
|
|
544
|
+
if (search === undefined) {
|
|
545
|
+
search = await findEngine(candidates)
|
|
546
|
+
if (search.engine === undefined) found.delete(key)
|
|
547
|
+
else found.set(key, search)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const report = {
|
|
551
|
+
ok: true,
|
|
552
|
+
sidecar: SIDECAR,
|
|
553
|
+
interpreter: search.interpreter,
|
|
554
|
+
attempts: [...search.attempts],
|
|
555
|
+
problems: [],
|
|
556
|
+
}
|
|
557
|
+
if (search.engine !== undefined) report.engine = search.engine
|
|
558
|
+
if (installable(config)) report.install = installStatus()
|
|
559
|
+
const problem = (code, message, fix) => {
|
|
560
|
+
report.problems.push({ code, message, fix })
|
|
561
|
+
report.ok = false
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!existsSync(SIDECAR)) {
|
|
565
|
+
problem('no_sidecar', `the sidecar is missing: ${SIDECAR}`,
|
|
566
|
+
'reinstall the package: sidecar/bridge.py ships beside lib/')
|
|
567
|
+
}
|
|
568
|
+
if (report.engine === undefined) problem(...missingEngine(config, report))
|
|
569
|
+
|
|
570
|
+
// The install command builds the engine; whether a browser is running is the doctor's question and
|
|
571
|
+
// the first browser call's. Reporting an unreachable attach target there would make `install` exit
|
|
572
|
+
// non-zero on a machine where it did its job (and on every CI runner with no Chrome running).
|
|
573
|
+
const checkBrowser = config.command !== 'install'
|
|
574
|
+
if (checkBrowser && config.mode === 'attach') {
|
|
575
|
+
// What this mode drives, and whether it is there now: the first browser call asks the same
|
|
576
|
+
// question, and the answer is what keeps "ready" from meaning only "the engine imports".
|
|
577
|
+
report.attach = await inspectAttach(config,
|
|
578
|
+
search.engine === undefined ? undefined : search.interpreter)
|
|
579
|
+
if (report.attach.code !== undefined) problem(report.attach.code, report.attach.message, report.attach.fix)
|
|
580
|
+
} else if (checkBrowser) {
|
|
581
|
+
report.browser = findBrowser(config)
|
|
582
|
+
if (report.browser === undefined) {
|
|
583
|
+
problem('no_browser', 'no Chrome or Chromium executable was found',
|
|
584
|
+
'install Chrome, or set --executable to the browser to launch')
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (config.jev?.enabled) report.goalMode = goalModeText(config)
|
|
588
|
+
return report
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The engine report, after installing the engine if this server owns the environment and finds none.
|
|
593
|
+
*
|
|
594
|
+
* The first caller starts the install and every caller meanwhile waits for that same one, so the
|
|
595
|
+
* startup check, a browser call, and a doctor call never run two installs. A recorded environment
|
|
596
|
+
* that stopped importing is rebuilt: installing again without `force` would only reuse the record.
|
|
597
|
+
* @param {object} [config] - resolved server configuration.
|
|
598
|
+
* @param {{fresh?: boolean, install?: boolean, onInstall?: (status: object) => void,
|
|
599
|
+
* onOutput?: (line: string) => void}} [options] - `install` retries a failed install at once
|
|
600
|
+
* instead of answering from it; `fresh` probes again first; `onInstall(status)` hears that an
|
|
601
|
+
* install is running; `onOutput(line)` receives the installer's output while it does.
|
|
602
|
+
* @returns {Promise<object>} the report after the install settled, or at once when nothing had to
|
|
603
|
+
* be installed.
|
|
604
|
+
*/
|
|
605
|
+
export async function ensureEngine(config = {}, options = {}) {
|
|
606
|
+
const report = await inspectEngine(config, { fresh: options.fresh === true })
|
|
607
|
+
if (report.engine !== undefined || !installable(config)) return report
|
|
608
|
+
const force = options.install === true
|
|
609
|
+
|| environmentRecord(ownEnvironment(config)) !== undefined
|
|
610
|
+
const install = installEngine(config, { force, onOutput: options.onOutput })
|
|
611
|
+
const status = installStatus()
|
|
612
|
+
if (status?.state === 'installing') options.onInstall?.(status)
|
|
613
|
+
await install
|
|
614
|
+
return inspectEngine(config, { fresh: true })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Drop the remembered probe answers, so the next check runs the probe again.
|
|
619
|
+
*
|
|
620
|
+
* A person who follows the doctor's fix expects the next check to see it; without this, the
|
|
621
|
+
* success-cache would be irrelevant but the install-a-moment-later case would not.
|
|
622
|
+
*/
|
|
623
|
+
export function forgetEngine() {
|
|
624
|
+
found.clear()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The browser a launched session would use: configured, then a PATH entry, then the macOS app.
|
|
629
|
+
*
|
|
630
|
+
* The order mirrors what `sidecar/bridge.py` does when it starts a browser, so a doctor that finds
|
|
631
|
+
* one here is not contradicted by the first browser call. A configured `executablePath` that does
|
|
632
|
+
* not exist falls through to discovery for the same reason.
|
|
633
|
+
* @param {object} [config] - resolved server configuration.
|
|
634
|
+
* @returns {string|undefined}
|
|
635
|
+
*/
|
|
636
|
+
export function findBrowser(config = {}) {
|
|
637
|
+
const configured = String(config.executablePath ?? '').trim()
|
|
638
|
+
if (configured !== '' && existsSync(configured)) return configured
|
|
639
|
+
const names = process.platform === 'win32'
|
|
640
|
+
? ['chrome.exe', 'chromium.exe']
|
|
641
|
+
: ['chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']
|
|
642
|
+
for (const directory of String(process.env.PATH ?? '').split(delimiter)) {
|
|
643
|
+
if (directory === '') continue
|
|
644
|
+
for (const name of names) {
|
|
645
|
+
const candidate = join(directory, name)
|
|
646
|
+
if (existsSync(candidate)) return candidate
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const mac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
650
|
+
return process.platform === 'darwin' && existsSync(mac) ? mac : undefined
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** One line on the install this server ran, or is running, for its own environment. */
|
|
654
|
+
function installLine(report) {
|
|
655
|
+
const install = report.install
|
|
656
|
+
if (install === undefined) {
|
|
657
|
+
return report.engine === undefined
|
|
658
|
+
? 'not run in this process'
|
|
659
|
+
: 'none in this process; the engine is already importable'
|
|
660
|
+
}
|
|
661
|
+
const how = install.strategy === 'uv'
|
|
662
|
+
? 'uv'
|
|
663
|
+
: install.strategy === 'python'
|
|
664
|
+
? `pip (Python ${install.pythonVersion ?? 'unknown'})`
|
|
665
|
+
: 'an installer'
|
|
666
|
+
if (install.state === 'installing') {
|
|
667
|
+
return `running for ${duration(Date.now() - install.startedAt)} into ${install.environment} with ${how} `
|
|
668
|
+
+ `(${install.step})`
|
|
669
|
+
}
|
|
670
|
+
const took = duration((install.finishedAt ?? Date.now()) - install.startedAt)
|
|
671
|
+
if (install.state === 'installed') return `installed into ${install.environment} with ${how} in ${took}`
|
|
672
|
+
if (install.code === 'no_python' || install.code === 'no_uv') return `not possible: ${install.error}`
|
|
673
|
+
return `failed after ${took}: ${install.error}`
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The report as text: what is ready, what is not, and the command that fixes each problem.
|
|
678
|
+
*
|
|
679
|
+
* The layout is the doctor's: fixed labels so a person can scan it, the interpreter and both engine
|
|
680
|
+
* versions named on their own lines, and every problem followed by its fix. `extra` is appended
|
|
681
|
+
* after the browser line, for a caller that has one more fact to show (a live session, say).
|
|
682
|
+
* @param {object} report - a report from `inspectEngine` or `ensureEngine`.
|
|
683
|
+
* @param {Record<string, string>} [extra] - additional `label -> line` pairs.
|
|
684
|
+
* @returns {string}
|
|
685
|
+
*/
|
|
686
|
+
export function reportText(report, extra = {}) {
|
|
687
|
+
const lines = []
|
|
688
|
+
if (report.engine === undefined) {
|
|
689
|
+
lines.push(`Engine : not usable \u2014 tried ${report.attempts.length} interpreter(s)`)
|
|
690
|
+
for (const item of report.attempts) {
|
|
691
|
+
lines.push(` ${item.command} (${item.source}): ${item.failure}`)
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
lines.push(`Engine : ${report.interpreter.command} (Python ${report.engine.python}, `
|
|
695
|
+
+ `chosen by ${report.interpreter.source})`)
|
|
696
|
+
lines.push(` jev_ultrafast ${report.engine.engine} at ${report.engine.enginePath}`)
|
|
697
|
+
lines.push(` browser-harness ${report.engine.browserHarness}`)
|
|
698
|
+
}
|
|
699
|
+
lines.push(`Install : ${installLine(report)}`)
|
|
700
|
+
lines.push(`Sidecar : ${report.sidecar}`)
|
|
701
|
+
if (report.attach !== undefined) {
|
|
702
|
+
lines.push(`Browser : ${report.attach.detail}`)
|
|
703
|
+
} else {
|
|
704
|
+
lines.push(`Browser : ${report.browser ?? 'none found'}`)
|
|
705
|
+
}
|
|
706
|
+
if (report.goalMode !== undefined) lines.push(`Goal mode: ${report.goalMode}`)
|
|
707
|
+
for (const [label, value] of Object.entries(extra)) {
|
|
708
|
+
lines.push(`${String(label).padEnd(9)}: ${value}`)
|
|
709
|
+
}
|
|
710
|
+
if (report.problems.length === 0) {
|
|
711
|
+
lines.push('Status : ready')
|
|
712
|
+
} else {
|
|
713
|
+
lines.push('Problems :')
|
|
714
|
+
for (const item of report.problems) lines.push(` - ${item.message}`, ` fix: ${item.fix}`)
|
|
715
|
+
}
|
|
716
|
+
return lines.join('\n')
|
|
717
|
+
}
|