@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.1

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/sidecar.js ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * One sidecar process: line-delimited JSON over stdio, one browser session inside.
3
+ *
4
+ * A sidecar is a *generation*: when its browser cannot be shut down cleanly the process is abandoned
5
+ * rather than reused, and the next browser this session opens gets a new generation with its own
6
+ * daemon name. Reusing a half-dead daemon is how a "close then reopen" turns into a tool call that
7
+ * fails for reasons nobody can see.
8
+ *
9
+ * Adapted from `dsh-browser-use` (`lib/sidecar.js`, MIT): the protocol is the vendored
10
+ * `sidecar/bridge.py`'s, unchanged.
11
+ *
12
+ * @module browser-use-mcp/sidecar
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+ import { createHash } from 'node:crypto'
17
+ import { readFileSync, unlinkSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
20
+ import { createInterface } from 'node:readline'
21
+
22
+ import { ensureEngine, reportText, SIDECAR } from './engine.js'
23
+ import { ENGINE_VARIABLES } from './jev.js'
24
+
25
+ /** The largest delay `setTimeout` accepts; anything above it is clamped to 1 ms by Node. */
26
+ const MAX_TIMER_MS = 2_147_483_647
27
+
28
+ /**
29
+ * How long a daemon socket path may be.
30
+ *
31
+ * The engine's IPC is an AF_UNIX socket, and `sun_path` is 104 bytes on macOS (108 on Linux), NUL
32
+ * included. A profile name is allowed to be long, and the daemon path is built from it, so the
33
+ * budget is checked before a browser is ever started: the failure would otherwise surface as the
34
+ * engine's opaque `fatal: AF_UNIX path too long` on every call.
35
+ */
36
+ const SOCKET_PATH_BUDGET = 100
37
+
38
+ /** The error a cancelled request settles with; the browser may still complete what it started. */
39
+ export function cancelled(signal) {
40
+ const error = new Error(
41
+ 'the operation was cancelled. The browser may still finish what it already started, '
42
+ + 'so read the page again with browser_page before acting on it.',
43
+ )
44
+ error.kind = 'cancelled'
45
+ if (signal?.reason !== undefined) error.cause = signal.reason
46
+ return error
47
+ }
48
+
49
+ /** The error a call settles with when the engine environment could not be made ready. */
50
+ export class EngineNotReady extends Error {
51
+ constructor(report) {
52
+ super(`the browser engine is not ready.\n\n${reportText(report)}`)
53
+ this.name = 'EngineNotReady'
54
+ this.kind = 'engine'
55
+ this.report = report
56
+ }
57
+ }
58
+
59
+ /** The error a call settles with when the engine install it waited for was cancelled. */
60
+ export function cancelledInstall(signal) {
61
+ const error = cancelled(signal)
62
+ error.message = 'the operation was cancelled while it waited for the browser engine install. '
63
+ + 'The install goes on: the next browser call waits for it, or uses what it installed.'
64
+ return error
65
+ }
66
+
67
+ /**
68
+ * Settle with `promise`, or with `failure(signal)` as soon as `signal` aborts.
69
+ *
70
+ * Only the wait is cancelled: the work behind `promise` goes on, and whoever asks for it next joins
71
+ * it rather than starting it again.
72
+ */
73
+ export function abortable(promise, signal, failure = cancelled) {
74
+ if (signal === undefined || signal === null) return promise
75
+ if (signal.aborted) {
76
+ promise.catch(() => {})
77
+ return Promise.reject(failure(signal))
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ const abort = () => reject(failure(signal))
81
+ signal.addEventListener('abort', abort, { once: true })
82
+ promise.then(
83
+ value => {
84
+ signal.removeEventListener('abort', abort)
85
+ resolve(value)
86
+ },
87
+ error => {
88
+ signal.removeEventListener('abort', abort)
89
+ reject(error)
90
+ },
91
+ )
92
+ })
93
+ }
94
+
95
+ /**
96
+ * The daemon name one generation owns.
97
+ *
98
+ * Browser Harness names every daemon and keeps a registration per name: a name a previous generation
99
+ * did not release cleanly would be handed back to the next launch. A generation suffix keeps that
100
+ * failure inside the generation that caused it.
101
+ * @param {string} label - the session's filesystem-safe label.
102
+ * @param {number} generation - 1 for the first browser this session opens.
103
+ */
104
+ export function daemonName(label, generation) {
105
+ const suffix = generation <= 1 ? '' : `-g${generation}`
106
+ const stem = String(label).replace(/[^A-Za-z0-9_-]/gu, '').slice(0, 64 - suffix.length) || 'session'
107
+ return `browser-use-mcp-${stem}${suffix}`
108
+ }
109
+
110
+ /**
111
+ * The environment a sidecar starts from: this process's, minus the engine's model variables.
112
+ *
113
+ * Jev's endpoints and keys travel with each request that spends them instead of living in the child,
114
+ * and one stale key in the server's own environment must not become every run's key. Browser
115
+ * Harness's shared-runtime switch is set when a runtime directory is inherited: several sessions may
116
+ * share it, and the engine would otherwise collapse their daemon names to one.
117
+ * @param {NodeJS.ProcessEnv} [source] - the environment to start from; `process.env` unless a caller says otherwise.
118
+ */
119
+ export function inheritedEnvironment(source = process.env) {
120
+ const env = { ...source }
121
+ for (const name of ENGINE_VARIABLES) delete env[name]
122
+ // One server may run several sidecars against one caller-supplied directory; Browser Harness would
123
+ // otherwise collapse every daemon name to the bare `bu` stem and let one session find — and stop —
124
+ // another session's daemon. Say the directory is shared whether it came in as BH_RUNTIME_DIR or as
125
+ // the legacy BH_TMP_DIR (the engine treats the latter as the runtime directory too).
126
+ if ((env.BH_RUNTIME_DIR || env.BH_TMP_DIR) && env.BH_RUNTIME_DIR_SHARED !== '1') {
127
+ env.BH_RUNTIME_DIR_SHARED = '1'
128
+ }
129
+ if (env.BH_TMP_DIR && env.BH_TMP_DIR_SHARED !== '1') env.BH_TMP_DIR_SHARED = '1'
130
+ return env
131
+ }
132
+
133
+ /** `~`/`~/…` expanded, the way the engine's own path handling does it. */
134
+ function expandHome(value) {
135
+ if (value === '~') return homedir()
136
+ if (value.startsWith('~/')) return join(homedir(), value.slice(1))
137
+ return value
138
+ }
139
+
140
+ /**
141
+ * Where Browser Harness keeps one daemon's pid and endpoint files.
142
+ *
143
+ * These are the engine's own documented conventions (`browser_harness/_ipc.py`, pinned by
144
+ * `vendor/requirements.txt`): the runtime directory comes from `BH_RUNTIME_DIR`, then `BH_TMP_DIR`,
145
+ * then `<config>/runtime`; the file stem is `bu-<name>` unless a caller-supplied runtime directory is
146
+ * declared isolated by not setting the `_SHARED` switch. This server starts several sidecars, so it
147
+ * always sets the shared switch and the stem always carries the daemon name.
148
+ */
149
+ function daemonFiles(env, name) {
150
+ const home = env.BH_HOME || env.BROWSER_HARNESS_HOME
151
+ || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'browser-harness') : join(homedir(), '.config', 'browser-harness'))
152
+ const caller = env.BH_RUNTIME_DIR || env.BH_TMP_DIR
153
+ const runtime = caller ? resolve(expandHome(caller)) : join(home, 'runtime')
154
+ const isolated = caller !== undefined && env.BH_RUNTIME_DIR_SHARED !== '1'
155
+ const stem = isolated ? 'bu' : `bu-${name}`
156
+ return {
157
+ pidPath: join(runtime, `${stem}.pid`),
158
+ sockPath: join(runtime, `${stem}.sock`),
159
+ portPath: join(runtime, `${stem}.port`),
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The daemon identity one session uses: its name and the files that name owns.
165
+ *
166
+ * A label long enough to push the socket path over the platform's `sun_path` limit is replaced by a
167
+ * stable short digest (same label and generation always give the same name, so a restarted sidecar
168
+ * still finds the daemon it left). When even that cannot fit, the runtime directory itself is the
169
+ * problem and the caller gets the variable that fixes it instead of the engine's opaque error.
170
+ */
171
+ export function daemonIdentity(env, label, generation) {
172
+ let name = daemonName(label, generation)
173
+ let files = daemonFiles(env, name)
174
+ if (files.sockPath.length > SOCKET_PATH_BUDGET) {
175
+ const digest = createHash('sha256').update(`${label}\u0000${generation}`).digest('hex').slice(0, 10)
176
+ name = `browser-use-mcp-${digest}`
177
+ files = daemonFiles(env, name)
178
+ }
179
+ if (files.sockPath.length > SOCKET_PATH_BUDGET) {
180
+ throw new Error(
181
+ `the Browser Harness runtime directory is too long for a Unix socket `
182
+ + `(${files.sockPath.length} > ${SOCKET_PATH_BUDGET} bytes): ${dirname(files.sockPath)}. `
183
+ + 'Set BH_RUNTIME_DIR to a shorter directory, or move the state root closer to /.',
184
+ )
185
+ }
186
+ return { name, files }
187
+ }
188
+
189
+ /** The pid Browser Harness published for one daemon, or undefined when it published none. */
190
+ function readDaemonPid(files) {
191
+ try {
192
+ const pid = Number(readFileSync(files.pidPath, 'utf8').trim())
193
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined
194
+ } catch {
195
+ return undefined
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Kill one orphaned daemon and drop the endpoint files it can no longer clean up itself.
201
+ *
202
+ * The bridge terminates the browser and then asks Browser Harness's admin layer to stop the daemon;
203
+ * that clean path can spend fifteen seconds waiting for a daemon that is already wedged, and an MCP
204
+ * client does not allow it. Once the browser is gone the daemon has nothing left to do, so a SIGTERM
205
+ * to the pid it published is the same end the slow path would reach — without the wait, and without
206
+ * leaving a process behind when this server exits. Every step is best effort: a daemon that already
207
+ * exited is not an error.
208
+ */
209
+ function stopDaemon(files, logger) {
210
+ const pid = readDaemonPid(files)
211
+ if (pid !== undefined) {
212
+ try {
213
+ process.kill(pid, 'SIGTERM')
214
+ logger?.debug?.(`sent SIGTERM to orphaned browser-harness daemon ${pid}`)
215
+ if (process.platform !== 'win32') {
216
+ // A daemon that ignores SIGTERM must still be gone; the detached shell survives this process.
217
+ spawn('/bin/sh', ['-c', `sleep 3; kill -9 ${pid} 2>/dev/null || true`], {
218
+ detached: true,
219
+ stdio: 'ignore',
220
+ }).unref()
221
+ }
222
+ } catch (error) {
223
+ logger?.debug?.(`could not signal browser-harness daemon ${pid}: ${error?.message ?? error}`)
224
+ }
225
+ }
226
+ for (const path of [files.pidPath, files.sockPath, files.portPath]) {
227
+ try {
228
+ unlinkSync(path)
229
+ } catch {
230
+ // Already cleaned by the daemon itself; nothing to do.
231
+ }
232
+ }
233
+ }
234
+
235
+ /** One sidecar process and the request/answer bookkeeping around it. */
236
+ export class Sidecar {
237
+ #child = null
238
+ #interpreter = null
239
+ #lines = null
240
+ #pending = new Map()
241
+ #sequence = 0
242
+ #stderr = []
243
+ #problem = ''
244
+ #daemon = null
245
+
246
+ /**
247
+ * @param {object} options
248
+ * @param {object} options.config - the resolved configuration.
249
+ * @param {string} options.label - a filesystem-safe label for this session.
250
+ * @param {number} [options.generation] - which generation of this session's browser it is.
251
+ * @param {object} [options.logger] - the stderr logger.
252
+ */
253
+ constructor({ config, label, generation = 1, logger }) {
254
+ this.config = config
255
+ this.label = label
256
+ this.generation = generation
257
+ this.logger = logger
258
+ }
259
+
260
+ /** Why this generation must not be reused, or an empty string while it is healthy. */
261
+ get problem() {
262
+ return this.#problem
263
+ }
264
+
265
+ /** Whether this generation still owns a usable process. */
266
+ get broken() {
267
+ return this.#problem !== ''
268
+ }
269
+
270
+ /** The interpreter the sidecar runs on, once a call has made it ready. */
271
+ get interpreter() {
272
+ return this.#interpreter
273
+ }
274
+
275
+ /**
276
+ * Check the engine, install it if this server owns its environment and finds none, then start the
277
+ * sidecar.
278
+ * @param {AbortSignal} [signal] - cancels this call's wait; an install it started goes on.
279
+ */
280
+ async ready(signal) {
281
+ if (this.broken) throw new Error(`this browser could not be cleaned up (${this.#problem})`)
282
+ const report = await abortable(ensureEngine(this.config), signal, cancelledInstall)
283
+ if (!report.ok) throw new EngineNotReady(report)
284
+ // The probe already found the interpreter that imports the engine: the sidecar runs on that one
285
+ // rather than on a second guess made here.
286
+ this.#interpreter = report.interpreter
287
+ await this.start()
288
+ }
289
+
290
+ async start() {
291
+ if (this.#child) return
292
+ const { command, prefix = [], cwd } = this.#interpreter ?? {}
293
+ if (!command) throw new Error('the browser engine probe found no interpreter to run the sidecar')
294
+ const environment = inheritedEnvironment()
295
+ // One daemon owns one CDP connection, and Browser Harness names its daemon when it imports.
296
+ const { name, files } = daemonIdentity(environment, this.label, this.generation)
297
+ const env = {
298
+ ...environment,
299
+ PYTHONUNBUFFERED: '1',
300
+ BU_NAME: name,
301
+ }
302
+ this.#daemon = { name, files }
303
+ const child = spawn(command, [...prefix, SIDECAR], {
304
+ cwd: cwd || undefined,
305
+ env,
306
+ stdio: ['pipe', 'pipe', 'pipe'],
307
+ })
308
+ this.#child = child
309
+ this.#lines = createInterface({ input: child.stdout })
310
+ this.#lines.on('line', line => this.#answered(line))
311
+ child.stderr.on('data', chunk => {
312
+ const text = String(chunk)
313
+ this.#stderr.push(text)
314
+ if (this.#stderr.length > 40) this.#stderr.shift()
315
+ this.logger?.debug?.(`sidecar stderr: ${text.trim()}`)
316
+ })
317
+ child.on('error', error => {
318
+ // An exit this session did not ask for (stop() clears #child first) means the generation is
319
+ // unusable: reusing its process, daemon name, or page is how a crash turns into a call that
320
+ // fails for reasons nobody can see.
321
+ const unexpected = this.#child === child
322
+ this.#child = null
323
+ if (unexpected) this.#problem = `the browser sidecar could not be started: ${error.message}`
324
+ this.#fail(new Error(`the browser sidecar could not be started: ${error.message}`))
325
+ })
326
+ child.on('exit', (code, signal) => {
327
+ const detail = this.#stderr.join('').trim().slice(-600)
328
+ const unexpected = this.#child === child
329
+ this.#child = null
330
+ const message = `the browser sidecar exited (${code ?? signal})${detail ? `: ${detail}` : ''}`
331
+ if (unexpected) {
332
+ this.#problem = message
333
+ // The sidecar is gone, but the Browser Harness daemon it started may still be running and
334
+ // holding a socket. This generation is never reused, so nothing else would ever stop it: a
335
+ // crash must not leave one Python process behind per session.
336
+ if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
337
+ }
338
+ this.#fail(new Error(message))
339
+ })
340
+ try {
341
+ await this.request('hello', {}, undefined, 120_000)
342
+ } catch (error) {
343
+ // A sidecar that cannot even greet is stopped here, so no half-started process is left behind.
344
+ await this.stop()
345
+ throw error
346
+ }
347
+ }
348
+
349
+ #fail(error) {
350
+ for (const [, entry] of this.#pending) entry.reject(error)
351
+ this.#pending.clear()
352
+ }
353
+
354
+ #answered(line) {
355
+ let answer
356
+ try {
357
+ answer = JSON.parse(line)
358
+ } catch {
359
+ return // a non-JSON line is the sidecar's own noise, never a protocol answer
360
+ }
361
+ const entry = this.#pending.get(answer.id)
362
+ if (!entry) return
363
+ if (answer.error) {
364
+ const error = Object.assign(new Error(answer.error.message ?? 'refused'), answer.error)
365
+ error.kind = answer.error.kind
366
+ entry.reject(error)
367
+ } else {
368
+ entry.resolve(answer.result)
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Send one request and settle it on the answer, the deadline, cancellation, or process death.
374
+ * @param {string} method - engine method name.
375
+ * @param {object} params - engine parameters.
376
+ * @param {AbortSignal} [signal] - the tool call's cancellation signal; aborting settles this promise only.
377
+ * @param {number} [timeoutMs] - deadline for this one request.
378
+ */
379
+ request(method, params = {}, signal, timeoutMs = this.config.requestTimeoutMs) {
380
+ const child = this.#child
381
+ if (!child) return Promise.reject(new Error('the browser sidecar is not running'))
382
+ if (signal?.aborted) return Promise.reject(cancelled(signal))
383
+ const id = ++this.#sequence
384
+ return new Promise((resolve, reject) => {
385
+ let settled = false
386
+ let timer
387
+ const finish = next => {
388
+ if (settled) return
389
+ settled = true
390
+ clearTimeout(timer)
391
+ this.#pending.delete(id)
392
+ signal?.removeEventListener('abort', onAbort)
393
+ next()
394
+ }
395
+ const onAbort = () => finish(() => reject(cancelled(signal)))
396
+ // `Infinity` (or any delay a timer cannot carry) means "no deadline": arming a timer with it
397
+ // would do the opposite, because Node clamps an oversized delay to one millisecond. A goal run
398
+ // is the caller that means this; its real bound is the MCP client's own timeout.
399
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= MAX_TIMER_MS) {
400
+ timer = setTimeout(() => {
401
+ finish(() => reject(new Error(`${method} did not answer within ${timeoutMs}ms`)))
402
+ }, timeoutMs)
403
+ }
404
+ this.#pending.set(id, {
405
+ resolve: value => finish(() => resolve(value)),
406
+ reject: error => finish(() => reject(error)),
407
+ })
408
+ signal?.addEventListener('abort', onAbort, { once: true })
409
+ try {
410
+ child.stdin.write(`${JSON.stringify({ id, method, params })}\n`)
411
+ } catch (error) {
412
+ finish(() => reject(error))
413
+ }
414
+ })
415
+ }
416
+
417
+ /**
418
+ * Ask the engine to stop the tab, the browser, and the daemon, then make sure the process is gone.
419
+ *
420
+ * The bridge terminates the launched browser *before* it stops the Browser Harness daemon, and that
421
+ * daemon cleanup can take fifteen seconds on this version. An MCP client closes a stdio server in
422
+ * about four, so waiting for the whole shutdown is not an option: the grace below covers the part
423
+ * that matters (the browser is gone within a few hundred milliseconds), and the sidecar is then
424
+ * killed while the daemon finishes on its own. A shutdown the engine reports as failed still marks
425
+ * this generation unusable, because the browser may not have been terminated.
426
+ * @param {object} [options]
427
+ * @param {number} [options.graceMs] - how long to wait for the engine's answer and its exit.
428
+ * @returns {Promise<string>} the shutdown problem, or an empty string when it ended as expected.
429
+ */
430
+ async stop({ graceMs = 1_000 } = {}) {
431
+ const child = this.#child
432
+ if (!child) {
433
+ // A sidecar that died asynchronously is reaped by its exit handler; this is the belt-and-braces
434
+ // path for a crash the handler could not finish, so a crashed generation does not leak a daemon.
435
+ if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
436
+ return this.#problem
437
+ }
438
+ const began = Date.now()
439
+ try {
440
+ await this.request('shutdown', {}, undefined, graceMs)
441
+ this.logger?.debug?.(`sidecar shut down in ${Date.now() - began}ms`)
442
+ } catch (error) {
443
+ // A client that closes fast leaves the daemon cleanup running; it does not mean the browser is
444
+ // still there — the bridge terminated it before starting that cleanup.
445
+ this.logger?.debug?.(`sidecar shutdown did not answer in ${graceMs}ms: ${error?.message ?? error}`)
446
+ }
447
+ this.#child = null
448
+ this.#fail(new Error('the browser sidecar was closed'))
449
+ let killed = false
450
+ if (child.exitCode === null && child.signalCode === null) {
451
+ child.stdin.end()
452
+ const ended = new Promise(resolve => child.once('exit', resolve))
453
+ // The answer already told us whether the browser got its terminate; a sidecar still alive here
454
+ // is finishing the daemon cleanup and can be killed without harming the browser.
455
+ const exited = await Promise.race([
456
+ ended.then(() => true),
457
+ new Promise(resolve => setTimeout(() => resolve(false), 400)),
458
+ ])
459
+ if (!exited && child.exitCode === null) {
460
+ killed = true
461
+ child.kill('SIGKILL')
462
+ }
463
+ }
464
+ // Whether this stop killed the sidecar or it managed to exit, this generation owns a daemon that
465
+ // must not outlive it; stopDaemon is best effort and idempotent.
466
+ if (this.#daemon !== null) stopDaemon(this.#daemon.files, this.logger)
467
+ if (this.#problem === '' && child.exitCode !== 0 && child.signalCode !== null && child.signalCode !== 'SIGKILL') {
468
+ this.#problem = `the sidecar was killed with ${child.signalCode}`
469
+ }
470
+ return this.#problem
471
+ }
472
+ }