@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/python.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding a Python 3.12+ and building the managed environment from it.
|
|
3
|
+
*
|
|
4
|
+
* The engine is a Python package (`jev_ultrafast`) with its own Python dependencies
|
|
5
|
+
* (`browser_harness`), and nothing npm installs builds a Python environment. So the server treats
|
|
6
|
+
* the engine as a runtime prerequisite it checks and — when it owns the environment — builds:
|
|
7
|
+
* this module is the bottom half of that, and answers three questions. Which `uv` exists, which
|
|
8
|
+
* `uv`-managed or system Python exists, and can one of them create a virtualenv for the sidecar.
|
|
9
|
+
*
|
|
10
|
+
* `uv` is preferred because it is the cheap path in both directions: `uv venv --python 3.12` also
|
|
11
|
+
* works on a machine with no suitable Python at all (uv may fetch one), and its `uv pip install`
|
|
12
|
+
* reads the same hash-pinned requirements file as pip does. A Python 3.12+ with no uv is the other
|
|
13
|
+
* supported shape: `python -m venv` plus pip. One rule matters more than the strategy: every
|
|
14
|
+
* interpreter is **probed** before it is used, so a macOS `python3` stub, an app-store shim, or a
|
|
15
|
+
* Python older than 3.12 is reported instead of being handed to `venv`.
|
|
16
|
+
*
|
|
17
|
+
* @module browser-use-mcp/python
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
import { rmSync, statSync } from 'node:fs'
|
|
22
|
+
import { delimiter, join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** The oldest Python the engine and its sidecar run on; both declare `>=3.12`. */
|
|
25
|
+
export const PYTHON_MINIMUM = '3.12'
|
|
26
|
+
|
|
27
|
+
const MINIMUM = [3, 12]
|
|
28
|
+
|
|
29
|
+
/** How long one interpreter has to answer the version probe before it is passed over. */
|
|
30
|
+
export const PROBE_TIMEOUT_MS = 15000
|
|
31
|
+
|
|
32
|
+
/** How long `python -m venv` may take when the caller names no budget. */
|
|
33
|
+
export const VENV_TIMEOUT_MS = 120000
|
|
34
|
+
|
|
35
|
+
/** How long uv may take: it is allowed to download a CPython before it creates the environment. */
|
|
36
|
+
export const UV_VENV_TIMEOUT_MS = 300000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The Python uv is asked to create the managed environment with.
|
|
40
|
+
*
|
|
41
|
+
* A fixed version rather than the newest installed one: the requirements are pinned and tested on
|
|
42
|
+
* 3.12, and uv finds that version on PATH or fetches it, so the same environment is built on every
|
|
43
|
+
* machine instead of depending on which Pythons happen to be newest.
|
|
44
|
+
*/
|
|
45
|
+
export const ENVIRONMENT_PYTHON_VERSION = '3.12'
|
|
46
|
+
|
|
47
|
+
const PYTHON_INSTALLER = process.platform === 'win32'
|
|
48
|
+
? 'winget install Python.Python.3.12'
|
|
49
|
+
: process.platform === 'darwin'
|
|
50
|
+
? 'brew install python@3.12'
|
|
51
|
+
: 'your distribution\u2019s python3.12 package (apt install python3.12 python3.12-venv)'
|
|
52
|
+
|
|
53
|
+
const UV_INSTALLER = process.platform === 'win32'
|
|
54
|
+
? 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'
|
|
55
|
+
: 'curl -LsSf https://astral.sh/uv/install.sh | sh (or brew install uv)'
|
|
56
|
+
|
|
57
|
+
/** The fix for "nothing on this machine can build the environment". */
|
|
58
|
+
const NOTHING_TO_BUILD_WITH = `install Python ${PYTHON_MINIMUM}+ (${PYTHON_INSTALLER}) or uv (${UV_INSTALLER})`
|
|
59
|
+
|
|
60
|
+
const PYTHON_VENV_FIX = `that Python cannot build a virtualenv: install its venv package (for example `
|
|
61
|
+
+ `python3.12-venv on Linux), or uv (${UV_INSTALLER})`
|
|
62
|
+
|
|
63
|
+
function isFile(path) {
|
|
64
|
+
try {
|
|
65
|
+
return statSync(path).isFile()
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `uv` binary on PATH, or `undefined`.
|
|
73
|
+
*
|
|
74
|
+
* PATH is the only place it is looked for on purpose: an installer puts it somewhere the user's
|
|
75
|
+
* shell can see, and inventing absolute locations would make the search depend on this machine.
|
|
76
|
+
* (The Windows installer ships `uv.exe`, hence the platform name.)
|
|
77
|
+
* @param {NodeJS.ProcessEnv} [env] - environment whose PATH is searched, `process.env` by default.
|
|
78
|
+
* @returns {string|undefined}
|
|
79
|
+
*/
|
|
80
|
+
export function findUv(env = process.env) {
|
|
81
|
+
const name = process.platform === 'win32' ? 'uv.exe' : 'uv'
|
|
82
|
+
for (const directory of String(env.PATH ?? '').split(delimiter)) {
|
|
83
|
+
if (directory === '') continue
|
|
84
|
+
const candidate = join(directory, name)
|
|
85
|
+
if (isFile(candidate)) return candidate
|
|
86
|
+
}
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The interpreter inside a virtualenv.
|
|
92
|
+
* @param {string} environment - the virtualenv directory.
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function environmentPython(environment) {
|
|
96
|
+
return process.platform === 'win32'
|
|
97
|
+
? join(environment, 'Scripts', 'python.exe')
|
|
98
|
+
: join(environment, 'bin', 'python')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The interpreter names a stock machine may expose on PATH.
|
|
103
|
+
*
|
|
104
|
+
* Versioned names first, highest first: on POSIX the newest installed Python is the likeliest to
|
|
105
|
+
* satisfy `>=3.12` and to be a real interpreter rather than a shim. Windows installs expose one
|
|
106
|
+
* `python.exe` (the versioned names are a POSIX convention), which is also why the Windows path
|
|
107
|
+
* can only ever use "the base Python".
|
|
108
|
+
*/
|
|
109
|
+
function systemPythonNames() {
|
|
110
|
+
return process.platform === 'win32'
|
|
111
|
+
? ['python.exe']
|
|
112
|
+
: ['python3.14', 'python3.13', 'python3.12', 'python3']
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ways this machine can build the environment, best first, each with the reason it is here.
|
|
117
|
+
*
|
|
118
|
+
* The configured `pythonPath`, when there is one, is an explicit user choice and comes first. uv
|
|
119
|
+
* comes next: it is not an interpreter — `probePython` must never be pointed at it — but it is the
|
|
120
|
+
* cheapest environment builder, because `uv venv --python 3.12` pins the version instead of taking
|
|
121
|
+
* whatever a PATH entry happens to be. The system Pythons come last, still in version order: they
|
|
122
|
+
* are what a machine without uv has, and each is probed before `createEnvironment` uses it.
|
|
123
|
+
*
|
|
124
|
+
* There are no absolute paths in this list; every system candidate is resolved through PATH by the
|
|
125
|
+
* spawn that probes it, which is what keeps the same code correct under Homebrew, pyenv, a Windows
|
|
126
|
+
* installer, or a shell that prepends its own bin directory.
|
|
127
|
+
* @param {object} [config] - resolved server configuration; only `config.pythonPath` is read.
|
|
128
|
+
* @returns {{command: string, prefix: string[], source: string}[]}
|
|
129
|
+
*/
|
|
130
|
+
export function basePythons(config = {}) {
|
|
131
|
+
const candidates = []
|
|
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, source })
|
|
135
|
+
}
|
|
136
|
+
const configured = String(config.pythonPath ?? '').trim()
|
|
137
|
+
if (configured !== '') add(configured, [], 'pythonPath')
|
|
138
|
+
const uv = findUv()
|
|
139
|
+
if (uv !== undefined) add(uv, [], 'uv')
|
|
140
|
+
for (const name of systemPythonNames()) add(name, [], 'system python')
|
|
141
|
+
return candidates
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether a probe's version is new enough to run the engine. */
|
|
145
|
+
function supported(probe) {
|
|
146
|
+
if (probe.major !== MINIMUM[0]) return probe.major > MINIMUM[0]
|
|
147
|
+
return probe.minor >= MINIMUM[1]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What to ask an interpreter: its own version, in the shape the rest of this module reads. */
|
|
151
|
+
const VERSION_PROBE = [
|
|
152
|
+
'import json, sys',
|
|
153
|
+
'json.dump({"version": ".".join(str(part) for part in sys.version_info[:3]),',
|
|
154
|
+
' "major": sys.version_info[0], "minor": sys.version_info[1]}, sys.stdout)',
|
|
155
|
+
].join('\n')
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Ask one interpreter what version it is.
|
|
159
|
+
*
|
|
160
|
+
* The probe is deliberately tiny: spawning a Python is the expensive part, and an interpreter that
|
|
161
|
+
* cannot answer this simple program cannot be trusted to build an environment either. Timeouts,
|
|
162
|
+
* spawn failures, and nonzero exits all become a `failure` string, so a broken `python3` on PATH is
|
|
163
|
+
* reported rather than thrown.
|
|
164
|
+
* @param {string} command - interpreter to run (a path or a PATH name).
|
|
165
|
+
* @param {string[]} [prefix] - arguments before `-c`, for launchers such as Windows' `py`.
|
|
166
|
+
* @returns {Promise<{version: string, major: number, minor: number}|{failure: string}>}
|
|
167
|
+
*/
|
|
168
|
+
export function probePython(command, prefix = []) {
|
|
169
|
+
return new Promise(resolve => {
|
|
170
|
+
let child
|
|
171
|
+
try {
|
|
172
|
+
child = spawn(command, [...prefix, '-c', VERSION_PROBE], {
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
})
|
|
176
|
+
} catch (error) {
|
|
177
|
+
resolve({ failure: `${command} could not be started: ${error.message}` })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
let out = ''
|
|
181
|
+
let err = ''
|
|
182
|
+
let settled = false
|
|
183
|
+
const settle = value => {
|
|
184
|
+
if (settled) return
|
|
185
|
+
settled = true
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
resolve(value)
|
|
188
|
+
}
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
child.kill('SIGKILL')
|
|
191
|
+
settle({ failure: `did not answer within ${Math.round(PROBE_TIMEOUT_MS / 1000)}s` })
|
|
192
|
+
}, PROBE_TIMEOUT_MS)
|
|
193
|
+
child.stdout.on('data', chunk => { out += String(chunk) })
|
|
194
|
+
child.stderr.on('data', chunk => { err += String(chunk) })
|
|
195
|
+
child.on('error', error => {
|
|
196
|
+
settle({ failure: `${command} could not be started: ${error.message}` })
|
|
197
|
+
})
|
|
198
|
+
child.on('close', code => {
|
|
199
|
+
if (code !== 0) {
|
|
200
|
+
const detail = err.trim().split('\n').filter(Boolean).at(-1) ?? `exit status ${code}`
|
|
201
|
+
settle({ failure: detail })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
let report
|
|
205
|
+
try {
|
|
206
|
+
report = JSON.parse(out)
|
|
207
|
+
} catch {
|
|
208
|
+
settle({ failure: `answered something unreadable: ${out.slice(0, 120)}` })
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if (typeof report.major !== 'number' || typeof report.minor !== 'number'
|
|
212
|
+
|| typeof report.version !== 'string') {
|
|
213
|
+
settle({ failure: `answered something unexpected: ${out.slice(0, 120)}` })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
settle({ version: report.version, major: report.major, minor: report.minor })
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One command, its output as lines, and how it ended.
|
|
223
|
+
*
|
|
224
|
+
* `timeoutMs` is the whole budget this command has: it is asked to stop, then killed. The caller
|
|
225
|
+
* decides what an exit code means; this only reports what happened. Output is streamed through
|
|
226
|
+
* `onLine` so an install can show progress without buffering everything until the end.
|
|
227
|
+
* @param {string} command
|
|
228
|
+
* @param {string[]} args
|
|
229
|
+
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, onLine?: (line: string) => void, timeoutMs?: number}} [options]
|
|
230
|
+
* @returns {Promise<{code: number|undefined, signal: string|undefined, timedOut: boolean,
|
|
231
|
+
* spawnError: string|undefined}>}
|
|
232
|
+
*/
|
|
233
|
+
export function runCommand(command, args, options = {}) {
|
|
234
|
+
return new Promise(resolve => {
|
|
235
|
+
let child
|
|
236
|
+
try {
|
|
237
|
+
child = spawn(command, args, {
|
|
238
|
+
cwd: options.cwd,
|
|
239
|
+
env: options.env,
|
|
240
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
})
|
|
243
|
+
} catch (error) {
|
|
244
|
+
resolve({ code: undefined, signal: undefined, timedOut: false, spawnError: error.message })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const pending = { stdout: '', stderr: '' }
|
|
248
|
+
const take = (stream, chunk) => {
|
|
249
|
+
const parts = (pending[stream] + String(chunk)).split(/\r\n|\r|\n/u)
|
|
250
|
+
pending[stream] = parts.pop()
|
|
251
|
+
for (const part of parts) {
|
|
252
|
+
if (part === '') continue
|
|
253
|
+
try {
|
|
254
|
+
options.onLine?.(part)
|
|
255
|
+
} catch {
|
|
256
|
+
// A listener that throws is its own problem; the command goes on for everyone else.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
child.stdout.on('data', chunk => take('stdout', chunk))
|
|
261
|
+
child.stderr.on('data', chunk => take('stderr', chunk))
|
|
262
|
+
let timedOut = false
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
timedOut = true
|
|
265
|
+
child.kill('SIGTERM')
|
|
266
|
+
setTimeout(() => child.kill('SIGKILL'), 5000).unref()
|
|
267
|
+
}, options.timeoutMs ?? VENV_TIMEOUT_MS)
|
|
268
|
+
child.on('error', error => {
|
|
269
|
+
clearTimeout(timer)
|
|
270
|
+
resolve({ code: undefined, signal: undefined, timedOut: false, spawnError: error.message })
|
|
271
|
+
})
|
|
272
|
+
child.on('close', (code, signal) => {
|
|
273
|
+
clearTimeout(timer)
|
|
274
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
275
|
+
if (pending[stream] !== '') {
|
|
276
|
+
try {
|
|
277
|
+
options.onLine?.(pending[stream])
|
|
278
|
+
} catch {
|
|
279
|
+
// As above: the command already finished.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
resolve({ code, signal, timedOut, spawnError: undefined })
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The environment `python -m venv` runs in: ours, minus anything that names another Python. */
|
|
289
|
+
function venvEnvironment() {
|
|
290
|
+
const env = { ...process.env, NO_COLOR: '1' }
|
|
291
|
+
delete env.VIRTUAL_ENV
|
|
292
|
+
delete env.PYTHONHOME
|
|
293
|
+
return env
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A failed environment build, in the shape the install reports and the doctor repeat. */
|
|
297
|
+
function failure(code, error, fix) {
|
|
298
|
+
return { ok: false, code, error, fix }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The last lines a builder printed, short enough to sit inside an error message. */
|
|
302
|
+
function lastWords(tail) {
|
|
303
|
+
const lines = tail.map(line => line.trim()).filter(Boolean).slice(-2)
|
|
304
|
+
if (lines.length === 0) return ''
|
|
305
|
+
const text = lines.join(' | ')
|
|
306
|
+
return `: ${text.length > 240 ? `${text.slice(0, 239)}\u2026` : text}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Remove the target environment before building it.
|
|
311
|
+
*
|
|
312
|
+
* A half-built environment from an earlier failed attempt, or a virtualenv built by a Python that
|
|
313
|
+
* has since disappeared, is worse than no environment: `venv` and `uv venv` would both try to
|
|
314
|
+
* repair a tree they did not create. This is this server's own directory, so clearing it is safe.
|
|
315
|
+
*/
|
|
316
|
+
function clearEnvironment(environment) {
|
|
317
|
+
try {
|
|
318
|
+
rmSync(environment, { recursive: true, force: true })
|
|
319
|
+
} catch {
|
|
320
|
+
// If it cannot be removed the builder reports that itself; failing to delete is not the error.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Create the environment with one base Python: `python -m venv`, then probe what it produced.
|
|
326
|
+
* @param {string[]} tail - the builder's output so far; its last lines explain a failure.
|
|
327
|
+
* @returns {Promise<object>} the create result, success or failure.
|
|
328
|
+
*/
|
|
329
|
+
async function createWithPython(candidate, probe, environment, say, tail) {
|
|
330
|
+
const label = candidate.source === 'pythonPath' ? 'the configured Python' : 'a Python this machine has'
|
|
331
|
+
say(`creating ${environment} with ${candidate.command} (${label}, Python ${probe.version})`)
|
|
332
|
+
const result = await runCommand(candidate.command, [...candidate.prefix, '-m', 'venv', environment], {
|
|
333
|
+
env: venvEnvironment(),
|
|
334
|
+
onLine: say,
|
|
335
|
+
timeoutMs: VENV_TIMEOUT_MS,
|
|
336
|
+
})
|
|
337
|
+
if (result.spawnError !== undefined) {
|
|
338
|
+
return failure('venv_failed', `${candidate.command} could not be started: ${result.spawnError}`, PYTHON_VENV_FIX)
|
|
339
|
+
}
|
|
340
|
+
if (result.timedOut) {
|
|
341
|
+
return failure('venv_failed',
|
|
342
|
+
`${candidate.command} -m venv did not finish within ${Math.round(VENV_TIMEOUT_MS / 1000)}s`
|
|
343
|
+
+ lastWords(tail), PYTHON_VENV_FIX)
|
|
344
|
+
}
|
|
345
|
+
if (result.code !== 0) {
|
|
346
|
+
return failure('venv_failed',
|
|
347
|
+
`${candidate.command} -m venv exited with ${result.code ?? result.signal}${lastWords(tail)}`,
|
|
348
|
+
PYTHON_VENV_FIX)
|
|
349
|
+
}
|
|
350
|
+
const inside = await probePython(environmentPython(environment))
|
|
351
|
+
if (inside.failure !== undefined) {
|
|
352
|
+
return failure('venv_failed',
|
|
353
|
+
`${candidate.command} -m venv created ${environment}, but the interpreter in it did not answer: `
|
|
354
|
+
+ inside.failure,
|
|
355
|
+
PYTHON_VENV_FIX)
|
|
356
|
+
}
|
|
357
|
+
if (!supported(inside)) {
|
|
358
|
+
return failure('venv_failed',
|
|
359
|
+
`${candidate.command} -m venv created ${environment} with Python ${inside.version}, older than `
|
|
360
|
+
+ `${PYTHON_MINIMUM}`, PYTHON_VENV_FIX)
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, python: environmentPython(environment), strategy: 'python', pythonVersion: inside.version }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Build the managed virtualenv, with uv when it exists and a probed Python 3.12+ otherwise.
|
|
367
|
+
*
|
|
368
|
+
* The two strategies are not equal: with uv the environment is created by a tool that can also
|
|
369
|
+
* fetch the pinned Python, so a machine with no suitable interpreter still succeeds; without uv the
|
|
370
|
+
* environment has to come from an interpreter already present. Both end in the same place — a
|
|
371
|
+
* virtualenv whose own `python` answered the version probe — and every command's output is passed
|
|
372
|
+
* to `onOutput` as it arrives, because a first install that downloads wheels has to show what it is
|
|
373
|
+
* doing for those seconds.
|
|
374
|
+
*
|
|
375
|
+
* The configured `pythonPath` is the answer or the failure: uv is not used over an explicitly named
|
|
376
|
+
* interpreter, because silently installing somewhere else would ignore the configuration.
|
|
377
|
+
* @param {{config?: object, envDir: string, onOutput?: (line: string) => void}} options
|
|
378
|
+
* @returns {Promise<{ok: true, python: string, strategy: 'uv'|'python', pythonVersion: string}
|
|
379
|
+
* | {ok: false, code: 'no_python'|'no_uv'|'venv_failed', error: string, fix: string}>}
|
|
380
|
+
*/
|
|
381
|
+
export async function createEnvironment({ config = {}, envDir, onOutput } = {}) {
|
|
382
|
+
if (typeof envDir !== 'string' || envDir.trim() === '') {
|
|
383
|
+
throw new TypeError('createEnvironment needs an envDir')
|
|
384
|
+
}
|
|
385
|
+
// The builder's output is streamed to the observer and its tail is kept for the failure message:
|
|
386
|
+
// "exit status 1" alone is not a diagnosis, but the line before it often is.
|
|
387
|
+
const tail = []
|
|
388
|
+
const say = line => {
|
|
389
|
+
tail.push(String(line))
|
|
390
|
+
if (tail.length > 20) tail.shift()
|
|
391
|
+
try {
|
|
392
|
+
onOutput?.(line)
|
|
393
|
+
} catch {
|
|
394
|
+
// An observer that throws must not fail the install for everyone else.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const configured = String(config.pythonPath ?? '').trim()
|
|
398
|
+
if (configured !== '') {
|
|
399
|
+
const probe = await probePython(configured)
|
|
400
|
+
if (probe.failure !== undefined) {
|
|
401
|
+
return failure('no_python',
|
|
402
|
+
`the configured --python (${configured}) cannot be used: ${probe.failure}`,
|
|
403
|
+
'correct --python, or clear it and the server builds an environment of its own')
|
|
404
|
+
}
|
|
405
|
+
if (!supported(probe)) {
|
|
406
|
+
return failure('no_python',
|
|
407
|
+
`the configured --python is Python ${probe.version}, older than ${PYTHON_MINIMUM}`,
|
|
408
|
+
`${NOTHING_TO_BUILD_WITH}, or point --python at a newer interpreter`)
|
|
409
|
+
}
|
|
410
|
+
clearEnvironment(envDir)
|
|
411
|
+
return createWithPython({ command: configured, prefix: [], source: 'pythonPath' }, probe, envDir, say, tail)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const uv = findUv()
|
|
415
|
+
let uvFailure
|
|
416
|
+
if (uv !== undefined) {
|
|
417
|
+
say(`creating ${envDir} with uv (Python ${ENVIRONMENT_PYTHON_VERSION})`)
|
|
418
|
+
clearEnvironment(envDir)
|
|
419
|
+
const result = await runCommand(uv, ['venv', '--python', ENVIRONMENT_PYTHON_VERSION, envDir], {
|
|
420
|
+
env: venvEnvironment(),
|
|
421
|
+
onLine: say,
|
|
422
|
+
timeoutMs: UV_VENV_TIMEOUT_MS,
|
|
423
|
+
})
|
|
424
|
+
if (result.spawnError !== undefined) {
|
|
425
|
+
uvFailure = `${uv} could not be started: ${result.spawnError}`
|
|
426
|
+
} else if (result.timedOut) {
|
|
427
|
+
uvFailure = `uv venv did not finish within ${Math.round(UV_VENV_TIMEOUT_MS / 1000)}s`
|
|
428
|
+
} else if (result.code !== 0) {
|
|
429
|
+
uvFailure = `uv venv exited with ${result.code ?? result.signal}${lastWords(tail)}`
|
|
430
|
+
} else {
|
|
431
|
+
const inside = await probePython(environmentPython(envDir))
|
|
432
|
+
if (inside.failure === undefined && supported(inside)) {
|
|
433
|
+
return { ok: true, python: environmentPython(envDir), strategy: 'uv', pythonVersion: inside.version }
|
|
434
|
+
}
|
|
435
|
+
uvFailure = inside.failure === undefined
|
|
436
|
+
? `the interpreter uv created is Python ${inside.version}, older than ${PYTHON_MINIMUM}`
|
|
437
|
+
: `the interpreter uv created did not answer: ${inside.failure}`
|
|
438
|
+
}
|
|
439
|
+
say(`uv could not create the environment (${uvFailure})`)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const candidates = basePythons(config).filter(item => item.source !== 'uv')
|
|
443
|
+
const attempts = []
|
|
444
|
+
for (const candidate of candidates) {
|
|
445
|
+
const probe = await probePython(candidate.command, candidate.prefix)
|
|
446
|
+
if (probe.failure !== undefined) {
|
|
447
|
+
attempts.push(`${candidate.command} (${candidate.source}): ${probe.failure}`)
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
if (!supported(probe)) {
|
|
451
|
+
attempts.push(`${candidate.command} (${candidate.source}): Python ${probe.version} `
|
|
452
|
+
+ `is older than ${PYTHON_MINIMUM}`)
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
clearEnvironment(envDir)
|
|
456
|
+
const created = await createWithPython(candidate, probe, envDir, say, tail)
|
|
457
|
+
if (created.ok === true) return created
|
|
458
|
+
attempts.push(`${candidate.command} (${candidate.source}): ${created.error}`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (attempts.length === 0 && uvFailure === undefined) {
|
|
462
|
+
return failure('no_python',
|
|
463
|
+
`no Python ${PYTHON_MINIMUM}+ was found on PATH `
|
|
464
|
+
+ `(tried ${systemPythonNames().join(', ')}) and uv is not installed`,
|
|
465
|
+
NOTHING_TO_BUILD_WITH)
|
|
466
|
+
}
|
|
467
|
+
const tried = attempts.length === 0 ? 'no usable base Python was found' : attempts.join('; ')
|
|
468
|
+
const because = uvFailure === undefined ? '' : `uv could not build the environment (${uvFailure}) and `
|
|
469
|
+
return failure('venv_failed', `${because}${tried}`, `${NOTHING_TO_BUILD_WITH}, then retry the install`)
|
|
470
|
+
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-visible rendering of one observation: `dsh-browser-use`'s action-space table.
|
|
3
|
+
*
|
|
4
|
+
* Screenshots are for people; a model reads this text instead. Every line names one element with the
|
|
5
|
+
* operations it supports, and every target it may send is an index printed here — never a selector,
|
|
6
|
+
* a coordinate, or code.
|
|
7
|
+
*
|
|
8
|
+
* @module browser-use-mcp/render
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Collapse whitespace so a page's own layout does not consume the model's context. */
|
|
12
|
+
export function collapse(text) {
|
|
13
|
+
return String(text ?? '').replace(/\s+/gu, ' ').trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checkbox, option and disclosure state: without it a model cannot tell whether a click is still
|
|
18
|
+
* needed. A tri-state value reads as `partly checked` rather than as either answer.
|
|
19
|
+
*/
|
|
20
|
+
export function elementState(element) {
|
|
21
|
+
const states = [
|
|
22
|
+
['checked', 'checked', 'not checked'],
|
|
23
|
+
['selected', 'selected', 'not selected'],
|
|
24
|
+
['expanded', 'expanded', 'collapsed'],
|
|
25
|
+
].flatMap(([key, on, off]) => {
|
|
26
|
+
const state = String(element[key] ?? '')
|
|
27
|
+
return state === 'true' ? [on] : state === 'false' ? [off] : state === 'mixed' ? [`partly ${on}`] : []
|
|
28
|
+
})
|
|
29
|
+
return states.length > 0 ? ` (${states.join(', ')})` : ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The action space of one page as text: its address, its visible text, and one line per element.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} page - one observation's page object from the sidecar.
|
|
36
|
+
* @param {number} observation - the observation number this table belongs to.
|
|
37
|
+
* @returns {string} the table the model acts from.
|
|
38
|
+
*/
|
|
39
|
+
export function pageText(page, observation) {
|
|
40
|
+
const lines = [
|
|
41
|
+
`Page ${page.url}${page.title ? ` — ${page.title}` : ''}`,
|
|
42
|
+
`Observation ${observation}. Targets below are valid only for this observation.`,
|
|
43
|
+
]
|
|
44
|
+
const text = collapse(page.text)
|
|
45
|
+
if (text) lines.push(`Visible text: ${text.slice(0, 1200)}${text.length > 1200 ? '…' : ''}`)
|
|
46
|
+
for (const element of page.elements ?? []) {
|
|
47
|
+
const value = collapse(element.value)
|
|
48
|
+
const details = [element.role ?? 'element', collapse(element.label)].filter(Boolean).join(' ')
|
|
49
|
+
lines.push(`[${element.index}] ${details}${value ? ` · "${value}"` : ''}${elementState(element)} — ${element.operations.join(', ')}`)
|
|
50
|
+
for (const option of element.options ?? []) lines.push(` ${option.index} → ${collapse(option.label)}`)
|
|
51
|
+
}
|
|
52
|
+
const withoutTarget = Object.keys(page.operations ?? {}).filter(operation => page.operations[operation].length === 0)
|
|
53
|
+
if (withoutTarget.length > 0) lines.push(`Without a target: ${withoutTarget.join(', ')}`)
|
|
54
|
+
if ((page.elements ?? []).length === 0) lines.push('No reachable element was found on this page.')
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A refusal that carries the table the next choice must come from.
|
|
60
|
+
*
|
|
61
|
+
* The point of the freshness guard is that a refusal is not an error to retry blindly: it hands back
|
|
62
|
+
* the observation the page is in now, so the agent chooses again from something that is true.
|
|
63
|
+
* @param {string} message - why nothing executed.
|
|
64
|
+
* @param {object} page - the fresh page.
|
|
65
|
+
* @param {number} observation - the fresh observation number.
|
|
66
|
+
*/
|
|
67
|
+
export function refusalText(message, page, observation) {
|
|
68
|
+
return `Nothing was executed: ${message}\n\n${pageText(page, observation)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A page's own title and address as one line, for results that do not need the whole table. */
|
|
72
|
+
export function pageLabel(page) {
|
|
73
|
+
if (!page) return 'no page has been read yet'
|
|
74
|
+
return `${page.title ? `${page.title} — ` : ''}${page.url}`
|
|
75
|
+
}
|