import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange, deriveDefaultBrowserControlPort, } from "../config/port-defaults.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_CONTROL_URL, DEFAULT_CLAWD_BROWSER_ENABLED, DEFAULT_CLAWD_BROWSER_PROFILE_NAME, } from "./constants.js"; import { CDP_PORT_RANGE_START } from "./profiles.js"; export type ResolvedBrowserConfig = { enabled: boolean; controlUrl: string; controlHost: string; controlPort: number; cdpProtocol: "http" | "https"; cdpHost: string; cdpIsLoopback: boolean; 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; }; 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_CLAWD_BROWSER_COLOR; const normalized = value.startsWith("#") ? value : `#${value}`; if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) return DEFAULT_CLAWD_BROWSER_COLOR; return normalized.toUpperCase(); } 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 "clawd" 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_CLAWD_BROWSER_PROFILE_NAME]) { result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = { cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, color: defaultColor, }; } return result; } export function resolveBrowserConfig( cfg: BrowserConfig | undefined, ): ResolvedBrowserConfig { const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim(); const derivedControlPort = (() => { const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim(); if (!raw) return null; const gatewayPort = Number.parseInt(raw, 10); if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null; return deriveDefaultBrowserControlPort(gatewayPort); })(); const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null; const controlInfo = parseHttpUrl( cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL, "browser.controlUrl", ); const controlPort = controlInfo.port; const defaultColor = normalizeHexColor(cfg?.color); 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( `browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`, ); } const derived = new URL(controlInfo.normalized); derived.port = String(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 defaultProfile = cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME; // Use legacy cdpUrl port for backward compatibility when no profiles configured const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const profiles = ensureDefaultProfile( cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; return { enabled, controlUrl: controlInfo.normalized, controlHost: controlInfo.parsed.hostname, controlPort, cdpProtocol, cdpHost: cdpInfo.parsed.hostname, cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), 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 = ""; 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, }; } export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) { return isLoopbackHost(resolved.controlHost); }