| 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/subsystem.js"; |
| import { CONFIG_DIR } from "../utils.js"; |
| import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js"; |
| import { appendCdpPath } from "./cdp.helpers.js"; |
| import { |
| type BrowserExecutable, |
| resolveBrowserExecutableForPlatform, |
| } from "./chrome.executables.js"; |
| import { |
| decorateOpenClawProfile, |
| ensureProfileCleanExit, |
| isProfileDecorated, |
| } from "./chrome.profile-decoration.js"; |
| import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; |
| import { |
| DEFAULT_OPENCLAW_BROWSER_COLOR, |
| DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, |
| } from "./constants.js"; |
|
|
| const log = createSubsystemLogger("browser").child("chrome"); |
|
|
| export type { BrowserExecutable } from "./chrome.executables.js"; |
| export { |
| findChromeExecutableLinux, |
| findChromeExecutableMac, |
| findChromeExecutableWindows, |
| resolveBrowserExecutableForPlatform, |
| } from "./chrome.executables.js"; |
| export { |
| decorateOpenClawProfile, |
| ensureProfileCleanExit, |
| isProfileDecorated, |
| } from "./chrome.profile-decoration.js"; |
|
|
| function exists(filePath: string) { |
| try { |
| return fs.existsSync(filePath); |
| } catch { |
| return false; |
| } |
| } |
|
|
| export type RunningChrome = { |
| pid: number; |
| exe: BrowserExecutable; |
| userDataDir: string; |
| cdpPort: number; |
| startedAt: number; |
| proc: ChildProcessWithoutNullStreams; |
| }; |
|
|
| function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null { |
| return resolveBrowserExecutableForPlatform(resolved, process.platform); |
| } |
|
|
| export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) { |
| return path.join(CONFIG_DIR, "browser", profileName, "user-data"); |
| } |
|
|
| function cdpUrlForPort(cdpPort: number) { |
| return `http://127.0.0.1:${cdpPort}`; |
| } |
|
|
| export async function isChromeReachable(cdpUrl: string, timeoutMs = 500): Promise<boolean> { |
| 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<ChromeVersion | null> { |
| const ctrl = new AbortController(); |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); |
| try { |
| const versionUrl = appendCdpPath(cdpUrl, "/json/version"); |
| const res = await fetch(versionUrl, { |
| signal: ctrl.signal, |
| headers: getHeadersWithAuth(versionUrl), |
| }); |
| 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<string | null> { |
| 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<boolean> { |
| return await new Promise<boolean>((resolve) => { |
| const headers = getHeadersWithAuth(wsUrl); |
| const ws = new WebSocket(wsUrl, { |
| handshakeTimeout: timeoutMs, |
| ...(Object.keys(headers).length ? { headers } : {}), |
| }); |
| const timer = setTimeout( |
| () => { |
| try { |
| ws.terminate(); |
| } catch { |
| |
| } |
| resolve(false); |
| }, |
| Math.max(50, timeoutMs + 25), |
| ); |
| ws.once("open", () => { |
| clearTimeout(timer); |
| try { |
| ws.close(); |
| } catch { |
| |
| } |
| resolve(true); |
| }); |
| ws.once("error", () => { |
| clearTimeout(timer); |
| resolve(false); |
| }); |
| }); |
| } |
|
|
| export async function isChromeCdpReady( |
| cdpUrl: string, |
| timeoutMs = 500, |
| handshakeTimeoutMs = 800, |
| ): Promise<boolean> { |
| const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs); |
| if (!wsUrl) { |
| return false; |
| } |
| return await canOpenWebSocket(wsUrl, handshakeTimeoutMs); |
| } |
|
|
| export async function launchOpenClawChrome( |
| resolved: ResolvedBrowserConfig, |
| profile: ResolvedBrowserProfile, |
| ): Promise<RunningChrome> { |
| 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/Brave/Edge/Chromium on macOS, Linux, or Windows).", |
| ); |
| } |
|
|
| const userDataDir = resolveOpenClawUserDataDir(profile.name); |
| fs.mkdirSync(userDataDir, { recursive: true }); |
|
|
| const needsDecorate = !isProfileDecorated( |
| userDataDir, |
| profile.name, |
| (profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(), |
| ); |
|
|
| |
| 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", |
| "--disable-session-crashed-bubble", |
| "--hide-crash-restore-bubble", |
| "--password-store=basic", |
| ]; |
|
|
| if (resolved.headless) { |
| |
| 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"); |
| } |
|
|
| |
| args.push("about:blank"); |
|
|
| return spawn(exe.path, args, { |
| stdio: "pipe", |
| env: { |
| ...process.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 (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 { |
| |
| } |
| const exitDeadline = Date.now() + 5000; |
| while (Date.now() < exitDeadline) { |
| if (bootstrap.exitCode != null) { |
| break; |
| } |
| await new Promise((r) => setTimeout(r, 50)); |
| } |
| } |
|
|
| if (needsDecorate) { |
| try { |
| decorateOpenClawProfile(userDataDir, { |
| name: profile.name, |
| color: profile.color, |
| }); |
| log.info(`🦞 openclaw browser profile decorated (${profile.color})`); |
| } catch (err) { |
| log.warn(`openclaw browser profile decoration failed: ${String(err)}`); |
| } |
| } |
|
|
| try { |
| ensureProfileCleanExit(userDataDir); |
| } catch (err) { |
| log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`); |
| } |
|
|
| const proc = spawnOnce(); |
| |
| 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 { |
| |
| } |
| throw new Error( |
| `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`, |
| ); |
| } |
|
|
| const pid = proc.pid ?? -1; |
| log.info( |
| `🦞 openclaw 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 stopOpenClawChrome(running: RunningChrome, timeoutMs = 2500) { |
| const proc = running.proc; |
| if (proc.killed) { |
| return; |
| } |
| try { |
| proc.kill("SIGTERM"); |
| } catch { |
| |
| } |
|
|
| 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 { |
| |
| } |
| } |
|
|