@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/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
+ }