import fs from "node:fs"; import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js"; import { BrowserConflictError, BrowserProfileNotFoundError, BrowserResourceExhaustedError, BrowserValidationError, } from "./errors.js"; import { allocateCdpPort, allocateColor, getUsedColors, getUsedPorts, isValidProfileName, } from "./profiles.js"; import type { BrowserRouteContext, ProfileStatus } from "./server-context.js"; import { movePathToTrash } from "./trash.js"; export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; driver?: "openclaw" | "extension"; }; export type CreateProfileResult = { ok: true; profile: string; cdpPort: number; cdpUrl: string; color: string; isRemote: boolean; }; export type DeleteProfileResult = { ok: true; profile: string; deleted: boolean; }; const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; const cdpPortRange = (resolved: { controlPort: number; cdpPortRangeStart?: number; cdpPortRangeEnd?: number; }): { start: number; end: number } => { const start = resolved.cdpPortRangeStart; const end = resolved.cdpPortRangeEnd; if ( typeof start === "number" && Number.isFinite(start) && Number.isInteger(start) && typeof end === "number" && Number.isFinite(end) && Number.isInteger(end) && start > 0 && end >= start && end <= 65535 ) { return { start, end }; } return deriveDefaultBrowserCdpPortRange(resolved.controlPort); }; export function createBrowserProfilesService(ctx: BrowserRouteContext) { const listProfiles = async (): Promise => { return await ctx.listProfiles(); }; const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; const driver = params.driver === "extension" ? "extension" : undefined; if (!isValidProfileName(name)) { throw new BrowserValidationError( "invalid profile name: use lowercase letters, numbers, and hyphens only", ); } const state = ctx.state(); const resolvedProfiles = state.resolved.profiles; if (name in resolvedProfiles) { throw new BrowserConflictError(`profile "${name}" already exists`); } const cfg = loadConfig(); const rawProfiles = cfg.browser?.profiles ?? {}; if (name in rawProfiles) { throw new BrowserConflictError(`profile "${name}" already exists`); } const usedColors = getUsedColors(resolvedProfiles); const profileColor = params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); let profileConfig: BrowserProfileConfig; if (rawCdpUrl) { const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); if (driver === "extension") { if (!isLoopbackHost(parsed.parsed.hostname)) { throw new BrowserValidationError( `driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`, ); } if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") { throw new BrowserValidationError( `driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`, ); } } profileConfig = { cdpUrl: parsed.normalized, ...(driver ? { driver } : {}), color: profileColor, }; } else { if (driver === "extension") { throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); } const usedPorts = getUsedPorts(resolvedProfiles); const range = cdpPortRange(state.resolved); const cdpPort = allocateCdpPort(usedPorts, range); if (cdpPort === null) { throw new BrowserResourceExhaustedError("no available CDP ports in range"); } profileConfig = { cdpPort, ...(driver ? { driver } : {}), color: profileColor, }; } const nextConfig: OpenClawConfig = { ...cfg, browser: { ...cfg.browser, profiles: { ...rawProfiles, [name]: profileConfig, }, }, }; await writeConfigFile(nextConfig); state.resolved.profiles[name] = profileConfig; const resolved = resolveProfile(state.resolved, name); if (!resolved) { throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`); } return { ok: true, profile: name, cdpPort: resolved.cdpPort, cdpUrl: resolved.cdpUrl, color: resolved.color, isRemote: !resolved.cdpIsLoopback, }; }; const deleteProfile = async (nameRaw: string): Promise => { const name = nameRaw.trim(); if (!name) { throw new BrowserValidationError("profile name is required"); } if (!isValidProfileName(name)) { throw new BrowserValidationError("invalid profile name"); } const cfg = loadConfig(); const profiles = cfg.browser?.profiles ?? {}; if (!(name in profiles)) { throw new BrowserProfileNotFoundError(`profile "${name}" not found`); } const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME; if (name === defaultProfile) { throw new BrowserValidationError( `cannot delete the default profile "${name}"; change browser.defaultProfile first`, ); } let deleted = false; const state = ctx.state(); const resolved = resolveProfile(state.resolved, name); if (resolved?.cdpIsLoopback) { try { await ctx.forProfile(name).stopRunningBrowser(); } catch { // ignore } const userDataDir = resolveOpenClawUserDataDir(name); const profileDir = path.dirname(userDataDir); if (fs.existsSync(profileDir)) { await movePathToTrash(profileDir); deleted = true; } } const { [name]: _removed, ...remainingProfiles } = profiles; const nextConfig: OpenClawConfig = { ...cfg, browser: { ...cfg.browser, profiles: remainingProfiles, }, }; await writeConfigFile(nextConfig); delete state.resolved.profiles[name]; state.profiles.delete(name); return { ok: true, profile: name, deleted }; }; return { listProfiles, createProfile, deleteProfile, }; }