@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/provision.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing the engine this server drives: one environment the package owns, built once.
|
|
3
|
+
*
|
|
4
|
+
* Nothing npm installs builds a Python environment, so the engine is treated as a runtime
|
|
5
|
+
* prerequisite: the package carries the engine as a wheel and its dependencies as hash-pinned
|
|
6
|
+
* requirements, and the first caller that finds no engine builds the environment from them with a
|
|
7
|
+
* Python the machine already has — or with uv, which can fetch the Python too. This module owns the
|
|
8
|
+
* environment directory, the install itself, and the record of what was installed; `./engine.js`
|
|
9
|
+
* decides when to call it.
|
|
10
|
+
*
|
|
11
|
+
* There is one environment per state root (`${BROWSER_USE_MCP_HOME}/env`), and there must be one
|
|
12
|
+
* install at a time: two server processes starting together would otherwise both build the same
|
|
13
|
+
* virtualenv. An in-process promise makes concurrent callers share one install, and an atomic lock
|
|
14
|
+
* directory (`<stateRoot>/install.lock`, with the owner's pid inside) does the same across
|
|
15
|
+
* processes. A lock whose owner is gone is reclaimed, so a killed install cannot wedge the next one.
|
|
16
|
+
*
|
|
17
|
+
* @module browser-use-mcp/provision
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { dirname, join, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
import { createEnvironment, environmentPython, findUv, runCommand } from './python.js'
|
|
26
|
+
|
|
27
|
+
/** This package's own root: the sidecar, the bundled engine, and the install hints live here. */
|
|
28
|
+
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
29
|
+
|
|
30
|
+
/** The bundled engine wheel: parsed from its filename, so a version bump needs no code change. */
|
|
31
|
+
const VENDOR = join(PACKAGE_ROOT, 'vendor')
|
|
32
|
+
|
|
33
|
+
/** The requirements file the lock pins, one wheel per line with the hash of every artifact. */
|
|
34
|
+
const REQUIREMENTS = join(VENDOR, 'requirements.txt')
|
|
35
|
+
|
|
36
|
+
/** How long one install may take from lock to wheel, as the caller's hard cap. */
|
|
37
|
+
export const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
|
|
38
|
+
|
|
39
|
+
/** How often a waiter looks at the lock again. A build takes seconds, so this is cheap enough. */
|
|
40
|
+
const LOCK_POLL_MS = 400
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long a lock directory without a readable owner is left alone.
|
|
44
|
+
*
|
|
45
|
+
* `mkdir` and writing `owner.json` are two steps: a waiter that read the directory in between would
|
|
46
|
+
* see no owner and, without this grace period, could delete a lock that was just created and then
|
|
47
|
+
* both processes would install. A few hundred milliseconds of patience is all it costs.
|
|
48
|
+
*/
|
|
49
|
+
const LOCK_GRACE_MS = 3000
|
|
50
|
+
|
|
51
|
+
/** The installer's output kept for the failure message; more is noise, less loses the cause. */
|
|
52
|
+
const KEPT_LINES = 40
|
|
53
|
+
|
|
54
|
+
/** The status fields observers see; the install's raw output and command stay internal. */
|
|
55
|
+
const STATUS_FIELDS = [
|
|
56
|
+
'state', 'step', 'startedAt', 'finishedAt', 'environment', 'strategy', 'pythonVersion', 'error',
|
|
57
|
+
'code', 'fix',
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** Environment dir -> the install running there in this process. */
|
|
61
|
+
const installs = new Map()
|
|
62
|
+
|
|
63
|
+
/** The in-process status of the last install this process ran, or `undefined` before one did. */
|
|
64
|
+
let status
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The state root: where the managed environment, the browser profiles, and the install record live.
|
|
68
|
+
*
|
|
69
|
+
* The configuration always resolves this, but a bare object still has to answer, so the documented
|
|
70
|
+
* default is applied here too. The directory itself is created on demand by the install — a server
|
|
71
|
+
* that only reads a report should not create anything.
|
|
72
|
+
* @param {object} [config] - resolved server configuration.
|
|
73
|
+
* @returns {string} the absolute state root.
|
|
74
|
+
*/
|
|
75
|
+
export function stateRoot(config = {}) {
|
|
76
|
+
const configured = String(config.browserUseMcpHome ?? '').trim()
|
|
77
|
+
return resolve(configured === '' ? join(homedir(), '.browser-use-mcp') : configured)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The environment this server builds and owns: `<stateRoot>/env`.
|
|
82
|
+
* @param {object} [config] - resolved server configuration.
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function ownEnvironment(config = {}) {
|
|
86
|
+
return join(stateRoot(config), 'env')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Where the install of the managed environment stands in this process, for observers such as the
|
|
91
|
+
* doctor or a waiter that joined a running install.
|
|
92
|
+
* @returns {{state: 'installing'|'installed'|'failed', step: string, startedAt: number,
|
|
93
|
+
* finishedAt?: number, environment: string, strategy?: string, pythonVersion?: string,
|
|
94
|
+
* error?: string, code?: string, fix?: string}|undefined} `undefined` before anything ran here.
|
|
95
|
+
*/
|
|
96
|
+
export function installStatus() {
|
|
97
|
+
if (status === undefined) return undefined
|
|
98
|
+
const copy = {}
|
|
99
|
+
for (const key of STATUS_FIELDS) {
|
|
100
|
+
if (status[key] !== undefined) copy[key] = status[key]
|
|
101
|
+
}
|
|
102
|
+
return copy
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The install record path that belongs to an environment, whether or not the record exists yet. */
|
|
106
|
+
function installRecordPath(environment) {
|
|
107
|
+
return join(dirname(environment), 'install.json')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The record of what built an environment, written by this module on success, or `undefined`.
|
|
112
|
+
*
|
|
113
|
+
* The record lives beside the environment (`<stateRoot>/install.json`) rather than inside it: a
|
|
114
|
+
* `python -m venv --clear` or a removed environment must not take the explanation of what happened
|
|
115
|
+
* with it, and a later doctor in another process reads it without importing anything.
|
|
116
|
+
* @param {string} environment - the managed virtualenv directory.
|
|
117
|
+
* @returns {object|undefined}
|
|
118
|
+
*/
|
|
119
|
+
export function environmentRecord(environment) {
|
|
120
|
+
try {
|
|
121
|
+
const record = JSON.parse(readFileSync(installRecordPath(environment), 'utf8'))
|
|
122
|
+
return record !== null && typeof record === 'object' ? record : undefined
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The hash-pinned requirements file for the sidecar's Python dependencies. */
|
|
129
|
+
export function requirementsPath() {
|
|
130
|
+
return REQUIREMENTS
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The bundled engine wheel, its version read from the wheel's filename.
|
|
135
|
+
*
|
|
136
|
+
* Reading the version from the filename rather than a separate manifest means the record, the
|
|
137
|
+
* doctor, and a version bump cannot disagree: they all describe the one file that is installed.
|
|
138
|
+
* @param {string} [root] - package root to look in; the installed package by default.
|
|
139
|
+
* @returns {{version: string, path: string}|undefined} `undefined` when no wheel ships here.
|
|
140
|
+
*/
|
|
141
|
+
export function bundledEngine(root = PACKAGE_ROOT) {
|
|
142
|
+
const vendor = join(root, 'vendor')
|
|
143
|
+
let names
|
|
144
|
+
try {
|
|
145
|
+
names = readdirSync(vendor)
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
const wheel = names.filter(name => /^jev_ultrafast-.+\.whl$/iu.test(name)).sort().at(-1)
|
|
150
|
+
if (wheel === undefined) return undefined
|
|
151
|
+
const version = wheel.split('-')[1]
|
|
152
|
+
if (version === undefined || version === '') return undefined
|
|
153
|
+
return { version, path: join(vendor, wheel) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The version a requirement pins, so the install record can name browser-harness accurately. */
|
|
157
|
+
function requirementVersion(name) {
|
|
158
|
+
try {
|
|
159
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
160
|
+
const match = new RegExp(`^${escaped}==([^\\s\\\\]+)`, 'mu').exec(readFileSync(REQUIREMENTS, 'utf8'))
|
|
161
|
+
return match?.[1] ?? 'unknown'
|
|
162
|
+
} catch {
|
|
163
|
+
return 'unknown'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How to ask for the install in this checkout, from a shell: the CLI command the package ships.
|
|
169
|
+
* @param {object} [config] - resolved server configuration (accepted for call sites that have it).
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function installHint(config = {}) {
|
|
173
|
+
void config
|
|
174
|
+
return `node ${join(PACKAGE_ROOT, 'bin', 'browser-use-mcp.mjs')} install`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** How to ask for the install again, appended to the end of a fix. */
|
|
178
|
+
function retryText(config) {
|
|
179
|
+
return `retry: ${installHint(config)}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A duration as a person reads it: `42s`, `3m`, `3m 5s`. */
|
|
183
|
+
export function duration(ms) {
|
|
184
|
+
const seconds = Math.max(0, Math.round(ms / 1000))
|
|
185
|
+
if (seconds < 60) return `${seconds}s`
|
|
186
|
+
const rest = seconds % 60
|
|
187
|
+
return rest === 0 ? `${Math.floor(seconds / 60)}m` : `${Math.floor(seconds / 60)}m ${rest}s`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The fix for "the installer could not reach the index", whatever it was downloading. */
|
|
191
|
+
function networkFix(config) {
|
|
192
|
+
return `check the network (a proxy belongs in HTTPS_PROXY for the process that runs this server); then `
|
|
193
|
+
+ retryText(config)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The diagnosis, shortened: the first error lines the installer printed, or its last words. */
|
|
197
|
+
function summarize(lines) {
|
|
198
|
+
const clipped = text => (text.length > 160 ? `${text.slice(0, 159)}\u2026` : text)
|
|
199
|
+
const errors = lines.map(line => line.trim()).filter(line => /^(?:ERROR|error):/u.test(line))
|
|
200
|
+
if (errors.length > 0) return [...new Set(errors.slice(-2))].map(clipped).join(' \u2014 ')
|
|
201
|
+
return lines.map(line => line.trim()).filter(Boolean).at(-1) ?? ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The npm/uv output that means the network, not the environment, is the problem. */
|
|
205
|
+
const NETWORK_FAILURE = new RegExp([
|
|
206
|
+
'files\\.pythonhosted\\.org', 'pypi\\.org', 'error sending request', 'tcp connect', 'dns error',
|
|
207
|
+
'connection (?:refused|reset)', 'timed out', 'tunnel error', 'certificate', 'network',
|
|
208
|
+
].join('|'), 'iu')
|
|
209
|
+
|
|
210
|
+
/** The fix for a failed install, decided by what the installer said. */
|
|
211
|
+
function fixFor(output, config) {
|
|
212
|
+
const retry = retryText(config)
|
|
213
|
+
if (/No matching distribution found|Could not find a version that satisfies/iu.test(output)) {
|
|
214
|
+
return `the index did not carry the pinned versions: check the network, or point PIP_INDEX_URL (or `
|
|
215
|
+
+ `UV_INDEX_URL) at a mirror of PyPI; then ${retry}`
|
|
216
|
+
}
|
|
217
|
+
if (/THESE PACKAGES DO NOT MATCH THE HASHES|Expected sha256|hash mismatch/iu.test(output)) {
|
|
218
|
+
return `an index served an artifact that is not the one requirements.txt records: point PIP_INDEX_URL `
|
|
219
|
+
+ `(or UV_INDEX_URL) at PyPI or a mirror of it; then ${retry}`
|
|
220
|
+
}
|
|
221
|
+
if (/No module named pip|ensurepip is not available|venv is not available/iu.test(output)) {
|
|
222
|
+
return `that Python cannot build a virtualenv with pip: install its venv package (for example `
|
|
223
|
+
+ `python3.12-venv on Linux) or uv; then ${retry}`
|
|
224
|
+
}
|
|
225
|
+
if (/permission denied|read-only file system|operation not permitted/iu.test(output)) {
|
|
226
|
+
return 'the state root (BROWSER_USE_MCP_HOME) has to be writable by the user this server runs as'
|
|
227
|
+
}
|
|
228
|
+
if (NETWORK_FAILURE.test(output)) {
|
|
229
|
+
return networkFix(config)
|
|
230
|
+
}
|
|
231
|
+
return `fix what the installer reported; then ${retry}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The environment pip and uv run in: the managed environment, and nothing that redirects them. */
|
|
235
|
+
function installEnvironment(environment) {
|
|
236
|
+
const env = { ...process.env, VIRTUAL_ENV: environment, NO_COLOR: '1' }
|
|
237
|
+
delete env.PIP_REQUIRE_VIRTUALENV
|
|
238
|
+
delete env.PYTHONHOME
|
|
239
|
+
return env
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The two commands that install with uv: the pinned dependencies, then the engine wheel from disk.
|
|
244
|
+
*
|
|
245
|
+
* uv validates the `--hash` lines in requirements.txt and, unlike a resolve, installs exactly what
|
|
246
|
+
* the file lists. The wheel is installed `--no-deps` because its dependencies are already there,
|
|
247
|
+
* which is what makes a first install about 1.3 MB instead of a full resolve.
|
|
248
|
+
*/
|
|
249
|
+
function uvInstallCommands(uv, environment, wheel) {
|
|
250
|
+
const python = environmentPython(environment)
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
command: uv,
|
|
254
|
+
args: ['pip', 'install', '--python', python, '-r', REQUIREMENTS],
|
|
255
|
+
what: 'installing the pinned dependencies with uv',
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
command: uv,
|
|
259
|
+
args: ['pip', 'install', '--python', python, '--no-deps', wheel],
|
|
260
|
+
what: 'installing the bundled engine',
|
|
261
|
+
},
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The same install with pip, for a machine without uv: hashes required, no dependencies resolved. */
|
|
266
|
+
function pipInstallCommands(environment, wheel) {
|
|
267
|
+
const python = environmentPython(environment)
|
|
268
|
+
const quiet = ['--no-input', '--disable-pip-version-check', '--progress-bar', 'off']
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
command: python,
|
|
272
|
+
args: ['-m', 'pip', 'install', ...quiet, '--require-hashes', '--no-deps', '--only-binary=:all:',
|
|
273
|
+
'-r', REQUIREMENTS],
|
|
274
|
+
what: 'installing the pinned dependencies with pip',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
command: python,
|
|
278
|
+
args: ['-m', 'pip', 'install', ...quiet, '--no-index', '--no-deps', wheel],
|
|
279
|
+
what: 'installing the bundled engine',
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The lock directory that makes one install at a time a property of the state root. */
|
|
285
|
+
function lockPath(root) {
|
|
286
|
+
return join(root, 'install.lock')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readLockOwner(lock) {
|
|
290
|
+
try {
|
|
291
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'))
|
|
292
|
+
return owner !== null && typeof owner === 'object' && Number.isInteger(owner.pid) ? owner : undefined
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Whether a process id still names a live process; `EPERM` means alive but not ours. */
|
|
299
|
+
function pidAlive(pid) {
|
|
300
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
301
|
+
try {
|
|
302
|
+
process.kill(pid, 0)
|
|
303
|
+
return true
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return error?.code === 'EPERM'
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Whether a lock may be removed: its owner is dead, or it never recorded an owner. */
|
|
310
|
+
function lockIsStale(lock) {
|
|
311
|
+
const owner = readLockOwner(lock)
|
|
312
|
+
// A readable owner is the fast path: a dead pid means the install was killed, and waiting for the
|
|
313
|
+
// grace period would only delay the next one. The grace period exists for the unreadable case
|
|
314
|
+
// below, where the directory may have been created a moment ago by a live installer.
|
|
315
|
+
if (owner !== undefined) return !pidAlive(owner.pid)
|
|
316
|
+
try {
|
|
317
|
+
return Date.now() - statSync(lock).mtimeMs > LOCK_GRACE_MS
|
|
318
|
+
} catch {
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One attempt to take the lock: `mkdir` is atomic, the owner file explains who holds it. */
|
|
324
|
+
function tryLock(lock) {
|
|
325
|
+
try {
|
|
326
|
+
mkdirSync(lock)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error?.code === 'EEXIST') return false
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const owner = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })
|
|
333
|
+
writeFileSync(join(lock, 'owner.json'), `${owner}\n`)
|
|
334
|
+
} catch {
|
|
335
|
+
// The lock is ours either way; an unreadable owner file only costs the reclaim grace period.
|
|
336
|
+
}
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function releaseLock(root) {
|
|
341
|
+
try {
|
|
342
|
+
rmSync(lockPath(root), { recursive: true, force: true })
|
|
343
|
+
} catch {
|
|
344
|
+
// A lock left behind is reclaimed by the next caller once this process is gone; never throw here.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function sleep(ms) {
|
|
349
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Wait for the cross-process install lock, reclaiming a stale one, and fail after a hard cap.
|
|
354
|
+
* @throws {Error} when another live install holds the lock for the whole budget.
|
|
355
|
+
*/
|
|
356
|
+
async function acquireInstallLock(root, config, onWait) {
|
|
357
|
+
const lock = lockPath(root)
|
|
358
|
+
const deadline = Date.now() + INSTALL_TIMEOUT_MS
|
|
359
|
+
for (;;) {
|
|
360
|
+
if (tryLock(lock)) return
|
|
361
|
+
if (lockIsStale(lock)) {
|
|
362
|
+
try {
|
|
363
|
+
rmSync(lock, { recursive: true, force: true })
|
|
364
|
+
continue
|
|
365
|
+
} catch {
|
|
366
|
+
// Another waiter may have reclaimed it first, or the directory refuses deletion; either way
|
|
367
|
+
// fall through to the wait so an undeletable lock cannot become a hot loop.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (Date.now() >= deadline) {
|
|
371
|
+
const owner = readLockOwner(lock)
|
|
372
|
+
const who = owner === undefined ? 'another process' : `process ${owner.pid}`
|
|
373
|
+
throw new Error(`${who} has held ${lock} for over ${duration(INSTALL_TIMEOUT_MS)}`)
|
|
374
|
+
}
|
|
375
|
+
onWait?.(`waiting for another install (${lock})`)
|
|
376
|
+
await sleep(LOCK_POLL_MS)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Write the record that tells a later process what built the environment. */
|
|
381
|
+
function writeInstallRecord(root, record) {
|
|
382
|
+
try {
|
|
383
|
+
mkdirSync(root, { recursive: true })
|
|
384
|
+
writeFileSync(join(root, 'install.json'), `${JSON.stringify(record, null, 2)}\n`)
|
|
385
|
+
} catch {
|
|
386
|
+
// The environment works even when the convenience record cannot be written.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A record that still describes a usable environment: one exists and its interpreter is there. */
|
|
391
|
+
function readyEnvironment(environment) {
|
|
392
|
+
const record = environmentRecord(environment)
|
|
393
|
+
if (record === undefined) return undefined
|
|
394
|
+
if (!existsSync(environmentPython(environment))) return undefined
|
|
395
|
+
return record
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** What a caller gets when the environment was already built: the same shape a fresh install has. */
|
|
399
|
+
function reusedResult(environment, record) {
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
environment: typeof record.environment === 'string' ? record.environment : environment,
|
|
403
|
+
strategy: record.strategy,
|
|
404
|
+
pythonVersion: record.pythonVersion,
|
|
405
|
+
engine: record.engine,
|
|
406
|
+
browserHarness: record.browserHarness,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Build the managed environment, or join the install already building it.
|
|
412
|
+
*
|
|
413
|
+
* The environment is reused when `install.json` records one and its interpreter exists — that is
|
|
414
|
+
* what makes a second call, and a server restart, free. Whether the engine actually *imports* is
|
|
415
|
+
* the caller's question (`inspectEngine` probes it); this module only builds and records. `force`
|
|
416
|
+
* is the user asking for a clean rebuild, and is what a caller passes when a recorded environment
|
|
417
|
+
* stopped working.
|
|
418
|
+
*
|
|
419
|
+
* Concurrent callers in this process share one promise and all receive the same result; another
|
|
420
|
+
* process waits on the lock, and when it gets it re-checks for a finished install before building.
|
|
421
|
+
* @param {object} [config] - resolved server configuration.
|
|
422
|
+
* @param {{force?: boolean, onOutput?: (line: string) => void}} [options] - `onOutput` receives the
|
|
423
|
+
* installer's lines from now until this install ends, whichever caller started it.
|
|
424
|
+
* @returns {Promise<{ok: boolean, environment: string, strategy?: string, pythonVersion?: string,
|
|
425
|
+
* engine?: string, browserHarness?: string, error?: string, code?: string, fix?: string,
|
|
426
|
+
* command?: string}>} the install's final state; it never rejects.
|
|
427
|
+
*/
|
|
428
|
+
export function installEngine(config = {}, options = {}) {
|
|
429
|
+
const root = stateRoot(config)
|
|
430
|
+
const environment = ownEnvironment(config)
|
|
431
|
+
const force = options.force === true
|
|
432
|
+
const current = installs.get(environment)
|
|
433
|
+
if (current !== undefined) {
|
|
434
|
+
if (typeof options.onOutput === 'function') current.listeners.add(options.onOutput)
|
|
435
|
+
return current.promise
|
|
436
|
+
}
|
|
437
|
+
if (!force) {
|
|
438
|
+
const record = readyEnvironment(environment)
|
|
439
|
+
if (record !== undefined) return Promise.resolve(reusedResult(environment, record))
|
|
440
|
+
}
|
|
441
|
+
const listeners = new Set()
|
|
442
|
+
if (typeof options.onOutput === 'function') listeners.add(options.onOutput)
|
|
443
|
+
const entry = { listeners }
|
|
444
|
+
installs.set(environment, entry)
|
|
445
|
+
entry.promise = buildEnvironment({ config, root, environment, force, listeners })
|
|
446
|
+
.finally(() => {
|
|
447
|
+
if (installs.get(environment) === entry) installs.delete(environment)
|
|
448
|
+
})
|
|
449
|
+
return entry.promise
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The actual install: status, lock, environment, two installs, record. */
|
|
453
|
+
async function buildEnvironment({ config, root, environment, force, listeners }) {
|
|
454
|
+
const startedAt = Date.now()
|
|
455
|
+
const output = []
|
|
456
|
+
status = { state: 'installing', step: 'preparing the install', startedAt, environment }
|
|
457
|
+
const say = text => {
|
|
458
|
+
const line = String(text)
|
|
459
|
+
if (line.trim() === '') return
|
|
460
|
+
output.push(line)
|
|
461
|
+
if (output.length > KEPT_LINES) output.shift()
|
|
462
|
+
status.step = line.trim().slice(0, 200)
|
|
463
|
+
for (const listener of listeners) {
|
|
464
|
+
try {
|
|
465
|
+
listener(line)
|
|
466
|
+
} catch {
|
|
467
|
+
// An observer that throws is its own problem; the install goes on for everyone else.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const failed = (code, error, fix) => {
|
|
472
|
+
Object.assign(status, { state: 'failed', finishedAt: Date.now(), code, error, fix })
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
environment,
|
|
476
|
+
strategy: status.strategy,
|
|
477
|
+
pythonVersion: status.pythonVersion,
|
|
478
|
+
error,
|
|
479
|
+
code,
|
|
480
|
+
fix,
|
|
481
|
+
...(status.command === undefined ? {} : { command: status.command }),
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const installed = record => {
|
|
485
|
+
Object.assign(status, {
|
|
486
|
+
state: 'installed',
|
|
487
|
+
finishedAt: Date.now(),
|
|
488
|
+
code: undefined,
|
|
489
|
+
error: undefined,
|
|
490
|
+
fix: undefined,
|
|
491
|
+
// A waiter that reuses another process's install records that install's strategy, so the
|
|
492
|
+
// doctor says how the environment was built even when this process did not build it.
|
|
493
|
+
strategy: record.strategy,
|
|
494
|
+
pythonVersion: record.pythonVersion,
|
|
495
|
+
})
|
|
496
|
+
return {
|
|
497
|
+
ok: true,
|
|
498
|
+
environment,
|
|
499
|
+
strategy: record.strategy,
|
|
500
|
+
pythonVersion: record.pythonVersion,
|
|
501
|
+
engine: record.engine,
|
|
502
|
+
browserHarness: record.browserHarness,
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
mkdirSync(root, { recursive: true })
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return failed('state_root_failed', `the state directory ${root} could not be created: ${error.message}`,
|
|
510
|
+
`make ${root} writable by the user this server runs as, or set BROWSER_USE_MCP_HOME to a writable directory`)
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
await acquireInstallLock(root, config, text => { status.step = text })
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return failed('lock_timeout', `another install never finished: ${error.message}`,
|
|
516
|
+
`if no install is running, remove ${lockPath(root)} and retry: ${installHint(config)}`)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
// Another process may have finished while this one waited; that install is the answer.
|
|
521
|
+
if (!force) {
|
|
522
|
+
const record = readyEnvironment(environment)
|
|
523
|
+
if (record !== undefined) {
|
|
524
|
+
status.step = 'another process already installed the environment'
|
|
525
|
+
return installed(record)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
status.step = 'creating the virtualenv'
|
|
529
|
+
const created = await createEnvironment({ config, envDir: environment, onOutput: say })
|
|
530
|
+
if (created.ok !== true) {
|
|
531
|
+
return failed(created.code, created.error, `${created.fix}; then ${retryText(config)}`)
|
|
532
|
+
}
|
|
533
|
+
status.strategy = created.strategy
|
|
534
|
+
status.pythonVersion = created.pythonVersion
|
|
535
|
+
const wheel = bundledEngine()
|
|
536
|
+
if (wheel === undefined || !existsSync(wheel.path)) {
|
|
537
|
+
return failed('no_wheel',
|
|
538
|
+
`the engine wheel this package carries is missing: ${wheel?.path ?? join(VENDOR, 'jev_ultrafast-*.whl')}`,
|
|
539
|
+
'reinstall the package: its Python half ships in vendor/ beside lib/')
|
|
540
|
+
}
|
|
541
|
+
const uv = findUv()
|
|
542
|
+
const commands = uv === undefined
|
|
543
|
+
? pipInstallCommands(environment, wheel.path)
|
|
544
|
+
: uvInstallCommands(uv, environment, wheel.path)
|
|
545
|
+
for (const step of commands) {
|
|
546
|
+
status.step = step.what
|
|
547
|
+
status.command = `${step.command} ${step.args.join(' ')}`
|
|
548
|
+
const remaining = Math.max(1000, INSTALL_TIMEOUT_MS - (Date.now() - startedAt))
|
|
549
|
+
const result = await runCommand(step.command, step.args, {
|
|
550
|
+
cwd: PACKAGE_ROOT,
|
|
551
|
+
env: installEnvironment(environment),
|
|
552
|
+
onLine: say,
|
|
553
|
+
timeoutMs: remaining,
|
|
554
|
+
})
|
|
555
|
+
if (result.timedOut) {
|
|
556
|
+
return failed('timeout', `${step.what} did not finish within ${duration(Date.now() - startedAt)}`,
|
|
557
|
+
networkFix(config))
|
|
558
|
+
}
|
|
559
|
+
if (result.spawnError !== undefined) {
|
|
560
|
+
return failed('install_failed', `${step.command} could not be started: ${result.spawnError}`,
|
|
561
|
+
`check ${step.command}; then ${retryText(config)}`)
|
|
562
|
+
}
|
|
563
|
+
if (result.code !== 0) {
|
|
564
|
+
return failed('install_failed',
|
|
565
|
+
summarize(output) || `${step.command} exited with ${result.code ?? result.signal}`,
|
|
566
|
+
fixFor(output.join('\n'), config))
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const record = {
|
|
570
|
+
strategy: created.strategy,
|
|
571
|
+
environment,
|
|
572
|
+
pythonVersion: created.pythonVersion,
|
|
573
|
+
installedAt: new Date().toISOString(),
|
|
574
|
+
engine: wheel.version,
|
|
575
|
+
browserHarness: requirementVersion('browser-harness'),
|
|
576
|
+
}
|
|
577
|
+
writeInstallRecord(root, record)
|
|
578
|
+
return installed(record)
|
|
579
|
+
} catch (error) {
|
|
580
|
+
return failed('install_failed', `the install itself failed: ${error?.message ?? String(error)}`,
|
|
581
|
+
`report this; then ${retryText(config)}`)
|
|
582
|
+
} finally {
|
|
583
|
+
releaseLock(root)
|
|
584
|
+
}
|
|
585
|
+
}
|