import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import WebSocket from "ws"; import { ensurePortAvailable } from "../infra/ports.js"; import { createSubsystemLogger } from "../logging.js"; import { CONFIG_DIR } from "../utils.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import type { ResolvedBrowserConfig, ResolvedBrowserProfile, } from "./config.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME, } from "./constants.js"; const log = createSubsystemLogger("browser").child("chrome"); export type BrowserExecutable = { kind: "canary" | "chromium" | "chrome" | "custom"; path: string; }; export type RunningChrome = { pid: number; exe: BrowserExecutable; userDataDir: string; cdpPort: number; startedAt: number; proc: ChildProcessWithoutNullStreams; }; function exists(filePath: string) { try { return fs.existsSync(filePath); } catch { return false; } } function findFirstExecutable( candidates: Array, ): BrowserExecutable | null { for (const candidate of candidates) { if (exists(candidate.path)) return candidate; } return null; } export function findChromeExecutableMac(): BrowserExecutable | null { const candidates: Array = [ { kind: "canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", }, { kind: "canary", path: path.join( os.homedir(), "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", ), }, { kind: "chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium", }, { kind: "chromium", path: path.join( os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium", ), }, { kind: "chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", }, { kind: "chrome", path: path.join( os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ), }, ]; return findFirstExecutable(candidates); } export function findChromeExecutableLinux(): BrowserExecutable | null { const candidates: Array = [ { kind: "chrome", path: "/usr/bin/google-chrome" }, { kind: "chrome", path: "/usr/bin/google-chrome-stable" }, { kind: "chromium", path: "/usr/bin/chromium" }, { kind: "chromium", path: "/usr/bin/chromium-browser" }, { kind: "chromium", path: "/snap/bin/chromium" }, { kind: "chrome", path: "/usr/bin/chrome" }, ]; return findFirstExecutable(candidates); } export function findChromeExecutableWindows(): BrowserExecutable | null { const localAppData = process.env.LOCALAPPDATA ?? ""; const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; // Must use bracket notation: variable name contains parentheses const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; const joinWin = path.win32.join; const candidates: Array = []; if (localAppData) { // Chrome Canary (user install) candidates.push({ kind: "canary", path: joinWin( localAppData, "Google", "Chrome SxS", "Application", "chrome.exe", ), }); // Chromium (user install) candidates.push({ kind: "chromium", path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"), }); // Chrome (user install) candidates.push({ kind: "chrome", path: joinWin( localAppData, "Google", "Chrome", "Application", "chrome.exe", ), }); } // Chrome (system install, 64-bit) candidates.push({ kind: "chrome", path: joinWin( programFiles, "Google", "Chrome", "Application", "chrome.exe", ), }); // Chrome (system install, 32-bit on 64-bit Windows) candidates.push({ kind: "chrome", path: joinWin( programFilesX86, "Google", "Chrome", "Application", "chrome.exe", ), }); return findFirstExecutable(candidates); } export function resolveBrowserExecutableForPlatform( resolved: ResolvedBrowserConfig, platform: NodeJS.Platform, ): BrowserExecutable | null { if (resolved.executablePath) { if (!exists(resolved.executablePath)) { throw new Error( `browser.executablePath not found: ${resolved.executablePath}`, ); } return { kind: "custom", path: resolved.executablePath }; } if (platform === "darwin") return findChromeExecutableMac(); if (platform === "linux") return findChromeExecutableLinux(); if (platform === "win32") return findChromeExecutableWindows(); return null; } function resolveBrowserExecutable( resolved: ResolvedBrowserConfig, ): BrowserExecutable | null { return resolveBrowserExecutableForPlatform(resolved, process.platform); } export function resolveClawdUserDataDir( profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME, ) { return path.join(CONFIG_DIR, "browser", profileName, "user-data"); } function decoratedMarkerPath(userDataDir: string) { return path.join(userDataDir, ".clawd-profile-decorated"); } function safeReadJson(filePath: string): Record | null { try { if (!exists(filePath)) return null; const raw = fs.readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; return parsed as Record; } catch { return null; } } function safeWriteJson(filePath: string, data: Record) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } function cdpUrlForPort(cdpPort: number) { return `http://127.0.0.1:${cdpPort}`; } function setDeep(obj: Record, keys: string[], value: unknown) { let node: Record = obj; for (const key of keys.slice(0, -1)) { const next = node[key]; if (typeof next !== "object" || next === null || Array.isArray(next)) { node[key] = {}; } node = node[key] as Record; } node[keys[keys.length - 1] ?? ""] = value; } function parseHexRgbToSignedArgbInt(hex: string): number | null { const cleaned = hex.trim().replace(/^#/, ""); if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null; const rgb = Number.parseInt(cleaned, 16); const argbUnsigned = (0xff << 24) | rgb; // Chrome stores colors as signed 32-bit ints (SkColor). return argbUnsigned > 0x7fffffff ? argbUnsigned - 0x1_0000_0000 : argbUnsigned; } function isProfileDecorated( userDataDir: string, desiredName: string, desiredColorHex: string, ): boolean { const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex); const localStatePath = path.join(userDataDir, "Local State"); const preferencesPath = path.join(userDataDir, "Default", "Preferences"); const localState = safeReadJson(localStatePath); const profile = localState?.profile; const infoCache = typeof profile === "object" && profile !== null && !Array.isArray(profile) ? (profile as Record).info_cache : null; const info = typeof infoCache === "object" && infoCache !== null && !Array.isArray(infoCache) && typeof (infoCache as Record).Default === "object" && (infoCache as Record).Default !== null && !Array.isArray((infoCache as Record).Default) ? ((infoCache as Record).Default as Record< string, unknown >) : null; const prefs = safeReadJson(preferencesPath); const browserTheme = (() => { const browser = prefs?.browser; const theme = typeof browser === "object" && browser !== null && !Array.isArray(browser) ? (browser as Record).theme : null; return typeof theme === "object" && theme !== null && !Array.isArray(theme) ? (theme as Record) : null; })(); const autogeneratedTheme = (() => { const autogenerated = prefs?.autogenerated; const theme = typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated) ? (autogenerated as Record).theme : null; return typeof theme === "object" && theme !== null && !Array.isArray(theme) ? (theme as Record) : null; })(); const nameOk = typeof info?.name === "string" ? info.name === desiredName : true; if (desiredColorInt == null) { // If the user provided a non-#RRGGBB value, we can only do best-effort. return nameOk; } const localSeedOk = typeof info?.profile_color_seed === "number" ? info.profile_color_seed === desiredColorInt : false; const prefOk = (typeof browserTheme?.user_color2 === "number" && browserTheme.user_color2 === desiredColorInt) || (typeof autogeneratedTheme?.color === "number" && autogeneratedTheme.color === desiredColorInt); return nameOk && localSeedOk && prefOk; } /** * Best-effort profile decoration (name + lobster-orange). Chrome preference keys * vary by version; we keep this conservative and idempotent. */ export function decorateClawdProfile( userDataDir: string, opts?: { name?: string; color?: string }, ) { const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME; const desiredColor = ( opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR ).toUpperCase(); const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor); const localStatePath = path.join(userDataDir, "Local State"); const preferencesPath = path.join(userDataDir, "Default", "Preferences"); const localState = safeReadJson(localStatePath) ?? {}; // Common-ish shape: profile.info_cache.Default setDeep( localState, ["profile", "info_cache", "Default", "name"], desiredName, ); setDeep( localState, ["profile", "info_cache", "Default", "shortcut_name"], desiredName, ); setDeep( localState, ["profile", "info_cache", "Default", "user_name"], desiredName, ); // Color keys are best-effort (Chrome changes these frequently). setDeep( localState, ["profile", "info_cache", "Default", "profile_color"], desiredColor, ); setDeep( localState, ["profile", "info_cache", "Default", "user_color"], desiredColor, ); if (desiredColorInt != null) { // These are the fields Chrome actually uses for profile/avatar tinting. setDeep( localState, ["profile", "info_cache", "Default", "profile_color_seed"], desiredColorInt, ); setDeep( localState, ["profile", "info_cache", "Default", "profile_highlight_color"], desiredColorInt, ); setDeep( localState, ["profile", "info_cache", "Default", "default_avatar_fill_color"], desiredColorInt, ); setDeep( localState, ["profile", "info_cache", "Default", "default_avatar_stroke_color"], desiredColorInt, ); } safeWriteJson(localStatePath, localState); const prefs = safeReadJson(preferencesPath) ?? {}; setDeep(prefs, ["profile", "name"], desiredName); setDeep(prefs, ["profile", "profile_color"], desiredColor); setDeep(prefs, ["profile", "user_color"], desiredColor); if (desiredColorInt != null) { // Chrome refresh stores the autogenerated theme in these prefs (SkColor ints). setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt); // User-selected browser theme color (pref name: browser.theme.user_color2). setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt); } safeWriteJson(preferencesPath, prefs); try { fs.writeFileSync( decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8", ); } catch { // ignore } } export async function isChromeReachable( cdpUrl: string, timeoutMs = 500, ): Promise { const version = await fetchChromeVersion(cdpUrl, timeoutMs); return Boolean(version); } type ChromeVersion = { webSocketDebuggerUrl?: string; Browser?: string; "User-Agent"?: string; }; async function fetchChromeVersion( cdpUrl: string, timeoutMs = 500, ): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const base = cdpUrl.replace(/\/$/, ""); const res = await fetch(`${base}/json/version`, { signal: ctrl.signal, }); if (!res.ok) return null; const data = (await res.json()) as ChromeVersion; if (!data || typeof data !== "object") return null; return data; } catch { return null; } finally { clearTimeout(t); } } export async function getChromeWebSocketUrl( cdpUrl: string, timeoutMs = 500, ): Promise { const version = await fetchChromeVersion(cdpUrl, timeoutMs); const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); if (!wsUrl) return null; return normalizeCdpWsUrl(wsUrl, cdpUrl); } async function canOpenWebSocket( wsUrl: string, timeoutMs = 800, ): Promise { return await new Promise((resolve) => { const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs }); const timer = setTimeout( () => { try { ws.terminate(); } catch { // ignore } resolve(false); }, Math.max(50, timeoutMs + 25), ); ws.once("open", () => { clearTimeout(timer); try { ws.close(); } catch { // ignore } resolve(true); }); ws.once("error", () => { clearTimeout(timer); resolve(false); }); }); } export async function isChromeCdpReady( cdpUrl: string, timeoutMs = 500, handshakeTimeoutMs = 800, ): Promise { const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs); if (!wsUrl) return false; return await canOpenWebSocket(wsUrl, handshakeTimeoutMs); } export async function launchClawdChrome( resolved: ResolvedBrowserConfig, profile: ResolvedBrowserProfile, ): Promise { if (!profile.cdpIsLoopback) { throw new Error( `Profile "${profile.name}" is remote; cannot launch local Chrome.`, ); } await ensurePortAvailable(profile.cdpPort); const exe = resolveBrowserExecutable(resolved); if (!exe) { throw new Error( "No supported browser found (Chrome/Chromium on macOS, Linux, or Windows).", ); } const userDataDir = resolveClawdUserDataDir(profile.name); fs.mkdirSync(userDataDir, { recursive: true }); const needsDecorate = !isProfileDecorated( userDataDir, profile.name, (profile.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(), ); // First launch to create preference files if missing, then decorate and relaunch. const spawnOnce = () => { const args: string[] = [ `--remote-debugging-port=${profile.cdpPort}`, `--user-data-dir=${userDataDir}`, "--no-first-run", "--no-default-browser-check", "--disable-sync", "--disable-background-networking", "--disable-component-update", "--disable-features=Translate,MediaRouter", "--password-store=basic", ]; if (resolved.headless) { // Best-effort; older Chromes may ignore. args.push("--headless=new"); args.push("--disable-gpu"); } if (resolved.noSandbox) { args.push("--no-sandbox"); args.push("--disable-setuid-sandbox"); } if (process.platform === "linux") { args.push("--disable-dev-shm-usage"); } // Always open a blank tab to ensure a target exists. args.push("about:blank"); return spawn(exe.path, args, { stdio: "pipe", env: { ...process.env, // Reduce accidental sharing with the user's env. HOME: os.homedir(), }, }); }; const startedAt = Date.now(); const localStatePath = path.join(userDataDir, "Local State"); const preferencesPath = path.join(userDataDir, "Default", "Preferences"); const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath); // If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults. // Then decorate (if needed) before the "real" run. if (needsBootstrap) { const bootstrap = spawnOnce(); const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (exists(localStatePath) && exists(preferencesPath)) break; await new Promise((r) => setTimeout(r, 100)); } try { bootstrap.kill("SIGTERM"); } catch { // ignore } const exitDeadline = Date.now() + 5000; while (Date.now() < exitDeadline) { if (bootstrap.exitCode != null) break; await new Promise((r) => setTimeout(r, 50)); } } if (needsDecorate) { try { decorateClawdProfile(userDataDir, { name: profile.name, color: profile.color, }); log.info(`🦞 clawd browser profile decorated (${profile.color})`); } catch (err) { log.warn(`clawd browser profile decoration failed: ${String(err)}`); } } const proc = spawnOnce(); // Wait for CDP to come up. const readyDeadline = Date.now() + 15_000; while (Date.now() < readyDeadline) { if (await isChromeReachable(profile.cdpUrl, 500)) break; await new Promise((r) => setTimeout(r, 200)); } if (!(await isChromeReachable(profile.cdpUrl, 500))) { try { proc.kill("SIGKILL"); } catch { // ignore } throw new Error( `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`, ); } const pid = proc.pid ?? -1; log.info( `🦞 clawd browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, ); return { pid, exe, userDataDir, cdpPort: profile.cdpPort, startedAt, proc, }; } export async function stopClawdChrome( running: RunningChrome, timeoutMs = 2500, ) { const proc = running.proc; if (proc.killed) return; try { proc.kill("SIGTERM"); } catch { // ignore } const start = Date.now(); while (Date.now() - start < timeoutMs) { if (!proc.exitCode && proc.killed) break; if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) return; await new Promise((r) => setTimeout(r, 100)); } try { proc.kill("SIGKILL"); } catch { // ignore } }