import { join } from 'path'; import { homedir } from 'os'; import { createConnection } from 'net'; import { spawn, execSync } from 'child_process'; import fs from 'fs'; const CDP_PORT = 9222; const CDP_HOST = '127.0.0.1'; const CDP_HEALTH_TIMEOUT_MS = 15_000; const HEALTH_CHECK_INTERVAL_MS = 60_000; const HEALTH_CHECK_FAIL_THRESHOLD = 3; let chromeProcess = null; let _consecutiveHealthFailures = 0; function _ts() { return new Date().toISOString(); } function _caller() { const stack = new Error().stack; const frames = stack?.split('\n').slice(2, 4).map(l => l.trim()).join(' <- ') ?? ''; return frames; } const LOCK_FILE_NAME = 'chrome-cdp.lock'; function getUserDataDir() { const configDir = process.env.PILOTDECK_CONFIG_DIR ?? join(homedir(), '.pilotdeck'); return join(configDir, 'browser-use-profile'); } function getLockFilePath() { return join(getUserDataDir(), LOCK_FILE_NAME); } function writeLock() { try { const dir = getUserDataDir(); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid, ts: Date.now() })); } catch { /* ignore */ } } function removeLock() { try { const lockPath = getLockFilePath(); if (!fs.existsSync(lockPath)) return; const content = fs.readFileSync(lockPath, 'utf8').trim(); const { pid } = JSON.parse(content); if (pid === process.pid) { fs.unlinkSync(lockPath); } } catch { /* ignore */ } } function findChromePath() { const platform = process.platform; const candidates = platform === 'darwin' ? [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', ] : ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium']; for (const c of candidates) { if (fs.existsSync(c)) return c; } return null; } function isCDPPortOpen() { return new Promise((resolve) => { const socket = createConnection({ host: CDP_HOST, port: CDP_PORT }); socket.setTimeout(1500); socket.on('connect', () => { socket.destroy(); resolve(true); }); socket.on('error', () => resolve(false)); socket.on('timeout', () => { socket.destroy(); resolve(false); }); }); } export async function isCDPHealthy() { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CDP_HEALTH_TIMEOUT_MS); const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, { signal: controller.signal, }); clearTimeout(timer); return res.ok; } catch { return false; } } function cleanSingletonLocks(dir) { for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) { const p = join(dir, name); try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ignore */ } } } function launchChrome(executablePath, userDataDir) { cleanSingletonLocks(userDataDir); const proc = spawn(executablePath, [ `--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', '--disable-features=ProfilePicker', ], { stdio: 'ignore', detached: true, }); proc.unref(); proc.on('exit', () => { if (chromeProcess === proc) chromeProcess = null; }); return proc; } async function waitForCDP(maxMs = 10_000) { const deadline = Date.now() + maxMs; while (Date.now() < deadline) { if (await isCDPHealthy()) return true; await new Promise((r) => setTimeout(r, 250)); } return false; } const CHROME_STOP_TIMEOUT_MS = 2500; const CHROME_STOP_POLL_MS = 100; async function killCDPPort() { const caller = _caller(); let pidList = []; try { const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim(); if (raw) pidList = raw.split('\n').map(Number).filter(Boolean); } catch { /* ignore */ } if (pidList.length === 0) { chromeProcess = null; return; } console.warn(`[BROWSER ${_ts()}] killCDPPort: sending SIGTERM to pids=${JSON.stringify(pidList)} | caller: ${caller}`); for (const pid of pidList) { try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ } } const deadline = Date.now() + CHROME_STOP_TIMEOUT_MS; while (Date.now() < deadline) { if (!(await isCDPHealthy())) { chromeProcess = null; return; } await new Promise((r) => setTimeout(r, CHROME_STOP_POLL_MS)); } console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`); for (const pid of pidList) { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } } await new Promise((r) => setTimeout(r, 300)); chromeProcess = null; } export async function ensureGlobalChrome() { if (await isCDPHealthy()) { return `http://${CDP_HOST}:${CDP_PORT}`; } if (await isCDPPortOpen()) { console.warn(`[BROWSER ${_ts()}] ensureGlobalChrome: port open but unhealthy, killing stale Chrome`); await killCDPPort(); } const executablePath = findChromePath(); if (!executablePath) return null; const userDataDir = getUserDataDir(); fs.mkdirSync(userDataDir, { recursive: true }); chromeProcess = launchChrome(executablePath, userDataDir); console.log(`[BROWSER ${_ts()}] ensureGlobalChrome: launched Chrome pid=${chromeProcess.pid}`); writeLock(); if (await waitForCDP()) { return `http://${CDP_HOST}:${CDP_PORT}`; } removeLock(); return null; } export async function restartGlobalChrome() { console.warn(`[BROWSER ${_ts()}] restartGlobalChrome: killing and relaunching | caller: ${_caller()}`); await killCDPPort(); const executablePath = findChromePath(); if (!executablePath) return null; const userDataDir = getUserDataDir(); fs.mkdirSync(userDataDir, { recursive: true }); chromeProcess = launchChrome(executablePath, userDataDir); console.log(`[BROWSER ${_ts()}] restartGlobalChrome: launched Chrome pid=${chromeProcess.pid}`); writeLock(); if (await waitForCDP()) { return `http://${CDP_HOST}:${CDP_PORT}`; } removeLock(); return null; } let healthCheckTimer = null; export function startChromeHealthCheck(intervalMs = HEALTH_CHECK_INTERVAL_MS) { stopChromeHealthCheck(); _consecutiveHealthFailures = 0; healthCheckTimer = setInterval(async () => { if (!(await isCDPHealthy())) { _consecutiveHealthFailures++; console.warn(`[BROWSER ${_ts()}] Health check failed (${_consecutiveHealthFailures}/${HEALTH_CHECK_FAIL_THRESHOLD})`); if (_consecutiveHealthFailures >= HEALTH_CHECK_FAIL_THRESHOLD) { console.warn(`[BROWSER ${_ts()}] ${HEALTH_CHECK_FAIL_THRESHOLD} consecutive failures, restarting Chrome...`); _consecutiveHealthFailures = 0; const url = await restartGlobalChrome(); if (url) { process.env.CDP_URL = url; console.log(`[BROWSER ${_ts()}] Chrome restarted at ${url}`); } else { console.error(`[BROWSER ${_ts()}] Chrome restart failed`); } } } else { if (_consecutiveHealthFailures > 0) { console.log(`[BROWSER ${_ts()}] Health check recovered after ${_consecutiveHealthFailures} failures`); } _consecutiveHealthFailures = 0; } }, intervalMs); healthCheckTimer.unref(); } export function stopChromeHealthCheck() { if (healthCheckTimer) { clearInterval(healthCheckTimer); healthCheckTimer = null; } } export function shutdownGlobalChrome() { console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome called | caller: ${_caller()}`); stopChromeHealthCheck(); if (chromeProcess) { console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome: sending SIGTERM to pid=${chromeProcess.pid}`); try { chromeProcess.kill('SIGTERM'); } catch { /* ignore */ } chromeProcess = null; } removeLock(); } let _cdpInitPromise = null; // Chrome 147+ breaks Playwright's connectOverCDP (setDownloadBehavior protocol // change). When the agent's session.ts detects this, it skips CDP and uses // chromium.launch() directly. We still start Chrome here so the health-check // infrastructure keeps working, but we tag the env so callers know CDP is // connect-incompatible. const CDP_INCOMPATIBLE_CHROME_MAJOR = 147; async function getChromeMajorFromCDP() { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3_000); const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, { signal: controller.signal, }); clearTimeout(timer); if (!res.ok) return 0; const data = await res.json(); const match = data.Browser?.match(/Chrome\/(\d+)/); return match ? parseInt(match[1], 10) : 0; } catch { return 0; } } /** * Lazy CDP initializer — starts Chrome only on first call, then caches the URL. * Subsequent calls return immediately if Chrome is already healthy. * Serializes concurrent callers so Chrome is launched at most once. * * On Chrome 147+, CDP_URL is intentionally NOT set so that the Agent's * browser-use session.ts falls through to Playwright-managed launch instead * of attempting connectOverCDP (which hangs on 147+). */ export async function ensureCDPUrl() { if (process.env.CDP_URL && await isCDPHealthy()) { return process.env.CDP_URL; } if (_cdpInitPromise) return _cdpInitPromise; _cdpInitPromise = (async () => { try { const cdpUrl = await ensureGlobalChrome(); if (!cdpUrl) return null; const major = await getChromeMajorFromCDP(); if (major >= CDP_INCOMPATIBLE_CHROME_MAJOR) { console.log( `[BROWSER ${_ts()}] Chrome ${major} detected — skipping CDP_URL ` + `(connectOverCDP incompatible). Agent will use Playwright-managed launch.` ); return null; } process.env.CDP_URL = cdpUrl; startChromeHealthCheck(HEALTH_CHECK_INTERVAL_MS); console.log(`[BROWSER ${_ts()}] Global Chrome ready (lazy) at ${cdpUrl}`); return cdpUrl; } finally { _cdpInitPromise = null; } })(); return _cdpInitPromise; }