import { spawn } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const HELPER = path.resolve(new URL('./helper.js', import.meta.url).pathname); const MISSING_NODE = 'Node.js is not available to jarvisd. Install Node, run npm run browser:install, set JARVIS_BROWSER_NODE to that node binary, and restart Jarvis.'; const MISSING_HELPER = 'The Jarvis browser helper is missing. Reinstall Jarvis so browser-use/helper.js is next to the daemon, then run npm run browser:install.'; function stateRoot() { return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local/state'), 'jarvis', 'browser'); } function existingFile(file) { const value = String(file || '').trim(); if (!value) return ''; try { return existsSync(value) ? value : ''; } catch { return ''; } } function addNodeCandidates(candidates, seen, file) { const value = String(file || '').trim(); if (!value || seen.has(value)) return; seen.add(value); candidates.push(value); } export function resolveNodeBinary(explicit = process.env.JARVIS_BROWSER_NODE || '') { const home = os.homedir(); const seen = new Set(); const candidates = []; addNodeCandidates(candidates, seen, explicit); addNodeCandidates(candidates, seen, process.env.JARVIS_BROWSER_NODE); addNodeCandidates(candidates, seen, '/usr/bin/node'); addNodeCandidates(candidates, seen, '/usr/local/bin/node'); addNodeCandidates(candidates, seen, path.join(home, '.local/bin/node')); addNodeCandidates(candidates, seen, path.join(home, '.volta/bin/node')); for (const dir of String(process.env.PATH || '').split(':')) { if (dir) addNodeCandidates(candidates, seen, path.join(dir, 'node')); } try { const nvm = path.join(home, '.nvm/versions/node'); for (const version of readdirSync(nvm).sort().reverse()) { addNodeCandidates(candidates, seen, path.join(nvm, version, 'bin/node')); } } catch {} for (const file of candidates) { const found = existingFile(file); if (found) return found; } return ''; } function spawnMessage(error, node, helper) { const raw = String(error?.message || error || ''); if (/enoent|no such file or directory/i.test(raw)) { if (!existingFile(node)) return MISSING_NODE; if (!existingFile(helper)) return MISSING_HELPER; return `${MISSING_NODE} (${raw})`; } return raw || 'Jarvis browser helper unavailable'; } /** JSON-lines client for the Node Playwright helper. Safe to import from Bare. */ export class BrowserClient { constructor({ node = process.env.JARVIS_BROWSER_NODE || 'node', command = process.env.JARVIS_BROWSER_HELPER || HELPER, spawnImpl = spawn, timeoutMs = 45_000, lookupBin = spawnImpl === spawn, } = {}) { this.node = node; this.command = command; this.spawnImpl = spawnImpl; this.timeoutMs = timeoutMs; this.lookupBin = lookupBin; this.process = null; this._ready = null; this._pending = new Map(); this._seq = 0; this._queue = Promise.resolve(); this._buffer = ''; this._closing = false; this._lastUrl = ''; this._reopens = 0; } async ensure() { if (this._ready) return this._ready; const node = this.lookupBin ? resolveNodeBinary(this.node) : this.node; const helper = this.command; if (this.lookupBin && !node) { this._ready = Promise.reject(new Error(MISSING_NODE)); this._ready.catch(() => {}); return this._ready; } if (this.lookupBin && !existingFile(helper)) { this._ready = Promise.reject(new Error(MISSING_HELPER)); this._ready.catch(() => {}); return this._ready; } const child = this.process = this.spawnImpl(node || this.node, [helper], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PATH: [node ? path.dirname(node) : '', process.env.PATH || '/usr/bin'].filter(Boolean).join(':'), PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(stateRoot(), 'ms-playwright'), JARVIS_BROWSER_PROFILE: process.env.JARVIS_BROWSER_PROFILE || path.join(stateRoot(), 'profile'), }, }); this._ready = new Promise((resolve, reject) => { let settled = false; let stderr = ''; const timer = setTimeout(() => fail(new Error('Jarvis browser helper timed out while starting')), this.timeoutMs); const fail = (error) => { clearTimeout(timer); if (this.process === child) this.process = null; this._ready = null; const wrapped = error instanceof Error ? error : new Error(String(error || 'Jarvis browser helper unavailable')); wrapped.message = spawnMessage(wrapped, node || this.node, helper); this._rejectAll(wrapped); if (!settled) { settled = true; reject(wrapped); } try { child.kill('SIGTERM'); } catch {} }; child.on('error', fail); child.once('close', () => { fail(new Error(stderr.trim() || 'Jarvis browser helper exited')); if (!this._closing) this._reopenWindow(); }); child.stderr?.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4000); }); child.stdout.on('data', (chunk) => this._onData(chunk, () => { if (settled) return; clearTimeout(timer); settled = true; resolve(this); }, fail)); }); return this._ready; } _onData(chunk, onReady, onError) { this._buffer += String(chunk); let index; while ((index = this._buffer.indexOf('\n')) >= 0) { const line = this._buffer.slice(0, index); this._buffer = this._buffer.slice(index + 1); let event; try { event = JSON.parse(line); } catch { continue; } if (event.type === 'error') { onError(new Error(event.reason || 'Jarvis browser helper unavailable')); continue; } if (event.type === 'ready') { onReady(); continue; } const pending = this._pending.get(event.id); if (!pending) continue; this._pending.delete(event.id); if (event.ok === false) pending.reject(new Error(event.error || 'browser action failed')); else pending.resolve(event.result); } } _rejectAll(error) { for (const pending of this._pending.values()) pending.reject(error); this._pending.clear(); } async call(action, payload = {}, timeoutMs) { const run = this._queue.then(() => this._send(action, payload, timeoutMs)); this._queue = run.catch(() => {}); try { const result = await run; if (result && result.url) this._lastUrl = String(result.url); this._reopens = 0; return result; } catch (error) { return { error: error?.message || 'Jarvis browser helper unavailable' }; } } _reopenWindow() { if (this._closing || this._reopens >= 2) return; this._reopens += 1; const url = this._lastUrl; setTimeout(() => { if (this._closing) return; this.ensure().then(() => (url ? this.call('navigate', { url }) : null)).catch(() => {}); }, 600); } async _send(action, payload, timeoutMs) { await this.ensure(); const child = this.process; if (!child?.stdin?.writable) throw new Error('Jarvis browser helper unavailable'); const id = ++this._seq; const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : this.timeoutMs; const result = new Promise((resolve, reject) => { const timer = setTimeout(() => { this._pending.delete(id); reject(new Error(`timed out after ${ms}ms`)); }, ms); this._pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); }); child.stdin.write(`${JSON.stringify({ id, action, timeoutMs: ms, ...payload })}\n`); return result; } close() { this._closing = true; const child = this.process; this._rejectAll(new Error('Jarvis browser helper closed')); this.process = null; this._ready = null; this._queue = Promise.resolve(); try { child?.kill?.('SIGTERM'); } catch {} } }