import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort } from "../config/paths.js"; import { deriveDefaultBrowserCdpPortRange, deriveDefaultBrowserControlPort, DEFAULT_BROWSER_CONTROL_PORT, } from "../config/port-defaults.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js"; export type ResolvedBrowserConfig = { enabled: boolean; evaluateEnabled: boolean; controlPort: number; cdpProtocol: "http" | "https"; cdpHost: string; cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; color: string; executablePath?: string; headless: boolean; noSandbox: boolean; attachOnly: boolean; defaultProfile: string; profiles: Record; }; export type ResolvedBrowserProfile = { name: string; cdpPort: number; cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; color: string; driver: "openclaw" | "extension"; }; function isLoopbackHost(host: string) { const h = host.trim().toLowerCase(); return ( h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h === "[::1]" || h === "::1" || h === "[::]" || h === "::" ); } function normalizeHexColor(raw: string | undefined) { const value = (raw ?? "").trim(); if (!value) { return DEFAULT_OPENCLAW_BROWSER_COLOR; } const normalized = value.startsWith("#") ? value : `#${value}`; if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) { return DEFAULT_OPENCLAW_BROWSER_COLOR; } return normalized.toUpperCase(); } function normalizeTimeoutMs(raw: number | undefined, fallback: number) { const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; return value < 0 ? fallback : value; } export function parseHttpUrl(raw: string, label: string) { const trimmed = raw.trim(); const parsed = new URL(trimmed); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`); } const port = parsed.port && Number.parseInt(parsed.port, 10) > 0 ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80; if (Number.isNaN(port) || port <= 0 || port > 65535) { throw new Error(`${label} has invalid port: ${parsed.port}`); } return { parsed, port, normalized: parsed.toString().replace(/\/$/, ""), }; } /** * Ensure the default "openclaw" profile exists in the profiles map. * Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing. */ function ensureDefaultProfile( profiles: Record | undefined, defaultColor: string, legacyCdpPort?: number, derivedDefaultCdpPort?: number, ): Record { const result = { ...profiles }; if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, color: defaultColor, }; } return result; } /** * Ensure a built-in "chrome" profile exists for the Chrome extension relay. * * Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile. * It points at the local relay CDP endpoint (controlPort + 1). */ function ensureDefaultChromeExtensionProfile( profiles: Record, controlPort: number, ): Record { const result = { ...profiles }; if (result.chrome) { return result; } const relayPort = controlPort + 1; if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) { return result; } // Avoid adding the built-in profile if the derived relay port is already used by another profile // (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP). if (getUsedPorts(result).has(relayPort)) { return result; } result.chrome = { driver: "extension", cdpUrl: `http://127.0.0.1:${relayPort}`, color: "#00AA00", }; return result; } export function resolveBrowserConfig( cfg: BrowserConfig | undefined, rootConfig?: OpenClawConfig, ): ResolvedBrowserConfig { const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED; const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; const gatewayPort = resolveGatewayPort(rootConfig); const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); const defaultColor = normalizeHexColor(cfg?.color); const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500); const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs( cfg?.remoteCdpHandshakeTimeoutMs, Math.max(2000, remoteCdpTimeoutMs * 2), ); const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); let cdpInfo: | { parsed: URL; port: number; normalized: string; } | undefined; if (rawCdpUrl) { cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl"); } else { const derivedPort = controlPort + 1; if (derivedPort > 65535) { throw new Error( `Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`, ); } const derived = new URL(`http://127.0.0.1:${derivedPort}`); cdpInfo = { parsed: derived, port: derivedPort, normalized: derived.toString().replace(/\/$/, ""), }; } const headless = cfg?.headless === true; const noSandbox = cfg?.noSandbox === true; const attachOnly = cfg?.attachOnly === true; const executablePath = cfg?.executablePath?.trim() || undefined; const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined; // Use legacy cdpUrl port for backward compatibility when no profiles configured const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const profiles = ensureDefaultChromeExtensionProfile( ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start), controlPort, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; const defaultProfile = defaultProfileFromConfig ?? (profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME] ? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME : DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); return { enabled, evaluateEnabled, controlPort, cdpProtocol, cdpHost: cdpInfo.parsed.hostname, cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), remoteCdpTimeoutMs, remoteCdpHandshakeTimeoutMs, color: defaultColor, executablePath, headless, noSandbox, attachOnly, defaultProfile, profiles, }; } /** * Resolve a profile by name from the config. * Returns null if the profile doesn't exist. */ export function resolveProfile( resolved: ResolvedBrowserConfig, profileName: string, ): ResolvedBrowserProfile | null { const profile = resolved.profiles[profileName]; if (!profile) { return null; } const rawProfileUrl = profile.cdpUrl?.trim() ?? ""; let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl = ""; const driver = profile.driver === "extension" ? "extension" : "openclaw"; if (rawProfileUrl) { const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); cdpHost = parsed.parsed.hostname; cdpPort = parsed.port; cdpUrl = parsed.normalized; } else if (cdpPort) { cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`; } else { throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`); } return { name: profileName, cdpPort, cdpUrl, cdpHost, cdpIsLoopback: isLoopbackHost(cdpHost), color: profile.color, driver, }; } export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) { return true; }