import fs from "node:fs"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { fetchJson, fetchOk } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { isChromeCdpReady, isChromeReachable, launchOpenClawChrome, resolveOpenClawUserDataDir, stopOpenClawChrome, } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; import { ensureChromeExtensionRelayServer, stopChromeExtensionRelayServer, } from "./extension-relay.js"; import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; import { refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload, } from "./resolved-config-refresh.js"; import type { BrowserServerState, BrowserRouteContext, BrowserTab, ContextOptions, ProfileContext, ProfileRuntimeState, ProfileStatus, } from "./server-context.types.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; import { movePathToTrash } from "./trash.js"; export type { BrowserRouteContext, BrowserServerState, BrowserTab, ProfileContext, ProfileRuntimeState, ProfileStatus, } from "./server-context.types.js"; export function listKnownProfileNames(state: BrowserServerState): string[] { const names = new Set(Object.keys(state.resolved.profiles)); for (const name of state.profiles.keys()) { names.add(name); } return [...names]; } const MAX_MANAGED_BROWSER_PAGE_TABS = 8; /** * Normalize a CDP WebSocket URL to use the correct base URL. */ function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined { if (!raw) { return undefined; } try { return normalizeCdpWsUrl(raw, cdpBaseUrl); } catch { return raw; } } /** * Create a profile-scoped context for browser operations. */ function createProfileContext( opts: ContextOptions, profile: ResolvedBrowserProfile, ): ProfileContext { const state = () => { const current = opts.getState(); if (!current) { throw new Error("Browser server not started"); } return current; }; const getProfileState = (): ProfileRuntimeState => { const current = state(); let profileState = current.profiles.get(profile.name); if (!profileState) { profileState = { profile, running: null, lastTargetId: null }; current.profiles.set(profile.name, profileState); } return profileState; }; const setProfileRunning = (running: ProfileRuntimeState["running"]) => { const profileState = getProfileState(); profileState.running = running; }; const listTabs = async (): Promise => { // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); return pages.map((p) => ({ targetId: p.targetId, title: p.title, url: p.url, type: p.type, })); } } const raw = await fetchJson< Array<{ id?: string; title?: string; url?: string; webSocketDebuggerUrl?: string; type?: string; }> >(appendCdpPath(profile.cdpUrl, "/json/list")); return raw .map((t) => ({ targetId: t.id ?? "", title: t.title ?? "", url: t.url ?? "", wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl), type: t.type, })) .filter((t) => Boolean(t.targetId)); }; const enforceManagedTabLimit = async (keepTargetId: string): Promise => { if (profile.driver !== "openclaw") { return; } const pageTabs = (await listTabs()).filter((tab) => (tab.type ?? "page") === "page"); if (pageTabs.length <= MAX_MANAGED_BROWSER_PAGE_TABS) { return; } const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MAX_MANAGED_BROWSER_PAGE_TABS; for (const tab of candidates.slice(0, excessCount)) { await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { // best-effort cleanup only }); } }; const openTab = async (url: string): Promise => { const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); // For remote profiles, use Playwright's persistent connection to create tabs // This ensures the tab persists beyond a single request if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; if (typeof createPageViaPlaywright === "function") { const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url, ...ssrfPolicyOpts, }); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; await enforceManagedTabLimit(page.targetId); return { targetId: page.targetId, title: page.title, url: page.url, type: page.type, }; } } const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, ...ssrfPolicyOpts, }) .then((r) => r.targetId) .catch(() => null); if (createdViaCdp) { const profileState = getProfileState(); profileState.lastTargetId = createdViaCdp; const deadline = Date.now() + 2000; while (Date.now() < deadline) { const tabs = await listTabs().catch(() => [] as BrowserTab[]); const found = tabs.find((t) => t.targetId === createdViaCdp); if (found) { await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); await enforceManagedTabLimit(found.targetId); return found; } await new Promise((r) => setTimeout(r, 100)); } await enforceManagedTabLimit(createdViaCdp); return { targetId: createdViaCdp, title: "", url, type: "page" }; } const encoded = encodeURIComponent(url); type CdpTarget = { id?: string; title?: string; url?: string; webSocketDebuggerUrl?: string; type?: string; }; const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new")); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const endpoint = endpointUrl.search ? (() => { endpointUrl.searchParams.set("url", url); return endpointUrl.toString(); })() : `${endpointUrl.toString()}?${encoded}`; const created = await fetchJson(endpoint, 1500, { method: "PUT", }).catch(async (err) => { if (String(err).includes("HTTP 405")) { return await fetchJson(endpoint, 1500); } throw err; }); if (!created.id) { throw new Error("Failed to open tab (missing id)"); } const profileState = getProfileState(); profileState.lastTargetId = created.id; const resolvedUrl = created.url ?? url; await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); await enforceManagedTabLimit(created.id); return { targetId: created.id, title: created.title ?? "", url: resolvedUrl, wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), type: created.type, }; }; const resolveRemoteHttpTimeout = (timeoutMs: number | undefined) => { if (profile.cdpIsLoopback) { return timeoutMs ?? 300; } const resolved = state().resolved; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs); } return resolved.remoteCdpTimeoutMs; }; const resolveRemoteWsTimeout = (timeoutMs: number | undefined) => { if (profile.cdpIsLoopback) { const base = timeoutMs ?? 300; return Math.max(200, Math.min(2000, base * 2)); } const resolved = state().resolved; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { return Math.max(Math.floor(timeoutMs) * 2, resolved.remoteCdpHandshakeTimeoutMs); } return resolved.remoteCdpHandshakeTimeoutMs; }; const isReachable = async (timeoutMs?: number) => { const httpTimeout = resolveRemoteHttpTimeout(timeoutMs); const wsTimeout = resolveRemoteWsTimeout(timeoutMs); return await isChromeCdpReady(profile.cdpUrl, httpTimeout, wsTimeout); }; const isHttpReachable = async (timeoutMs?: number) => { const httpTimeout = resolveRemoteHttpTimeout(timeoutMs); return await isChromeReachable(profile.cdpUrl, httpTimeout); }; const attachRunning = (running: NonNullable) => { setProfileRunning(running); running.proc.on("exit", () => { // Guard against server teardown (e.g., SIGUSR1 restart) if (!opts.getState()) { return; } const profileState = getProfileState(); if (profileState.running?.pid === running.pid) { setProfileRunning(null); } }); }; const ensureBrowserAvailable = async (): Promise => { const current = state(); const remoteCdp = !profile.cdpIsLoopback; const attachOnly = profile.attachOnly; const isExtension = profile.driver === "extension"; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); const waitForCdpReadyAfterLaunch = async () => { // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. // If a follow-up call (snapshot/screenshot/etc.) races ahead, we can hit PortInUseError trying to // launch again on the same port. Poll briefly so browser(action="start"/"open") is stable. // // Bound the wait by wall-clock time to avoid long stalls when /json/version is reachable // but the CDP WebSocket never becomes ready. const deadlineMs = Date.now() + 8000; while (Date.now() < deadlineMs) { const remainingMs = Math.max(0, deadlineMs - Date.now()); // Keep each attempt short; loopback profiles derive a WS timeout from this value. const attemptTimeoutMs = Math.max(75, Math.min(250, remainingMs)); if (await isReachable(attemptTimeoutMs)) { return; } await new Promise((r) => setTimeout(r, 100)); } throw new Error( `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, ); }; if (isExtension && remoteCdp) { throw new Error( `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, ); } if (isExtension) { if (!httpReachable) { await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }); if (!(await isHttpReachable(1200))) { throw new Error( `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, ); } } // Browser startup should only ensure relay availability. // Tab attachment is checked when a tab is actually required. return; } if (!httpReachable) { if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); if (await isHttpReachable(1200)) { return; } } if (attachOnly || remoteCdp) { throw new Error( remoteCdp ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, ); } const launched = await launchOpenClawChrome(current.resolved, profile); attachRunning(launched); try { await waitForCdpReadyAfterLaunch(); } catch (err) { await stopOpenClawChrome(launched).catch(() => {}); setProfileRunning(null); throw err; } return; } // Port is reachable - check if we own it if (await isReachable()) { return; } // HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform // local ownership/restart handling; just run attach retries and surface attach errors. if (attachOnly || remoteCdp) { if (opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); if (await isReachable(1200)) { return; } } throw new Error( remoteCdp ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`, ); } // HTTP responds but WebSocket fails - port in use by something else. if (!profileState.running) { throw new Error( `Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + `Run action=reset-profile profile=${profile.name} to kill the process.`, ); } // We own it but WebSocket failed - restart // At this point profileState.running is always non-null: the !remoteCdp guard // above throws when running is null, and attachOnly/remoteCdp paths always // exit via the block above. Add an explicit guard for TypeScript. if (!profileState.running) { throw new Error( `Unexpected state for profile "${profile.name}": no running process to restart.`, ); } await stopOpenClawChrome(profileState.running); setProfileRunning(null); const relaunched = await launchOpenClawChrome(current.resolved, profile); attachRunning(relaunched); if (!(await isReachable(600))) { throw new Error( `Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`, ); } }; const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); const tabs1 = await listTabs(); if (tabs1.length === 0) { if (profile.driver === "extension") { throw new Error( `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", ); } await openTab("about:blank"); } const tabs = await listTabs(); // For remote profiles using Playwright's persistent connection, we don't need wsUrl // because we access pages directly through Playwright, not via individual WebSocket URLs. const candidates = profile.driver === "extension" || !profile.cdpIsLoopback ? tabs : tabs.filter((t) => Boolean(t.wsUrl)); const resolveById = (raw: string) => { const resolved = resolveTargetIdFromTabs(raw, candidates); if (!resolved.ok) { if (resolved.reason === "ambiguous") { return "AMBIGUOUS" as const; } return null; } return candidates.find((t) => t.targetId === resolved.targetId) ?? null; }; const pickDefault = () => { const last = profileState.lastTargetId?.trim() || ""; const lastResolved = last ? resolveById(last) : null; if (lastResolved && lastResolved !== "AMBIGUOUS") { return lastResolved; } // Prefer a real page tab first (avoid service workers/background targets). const page = candidates.find((t) => (t.type ?? "page") === "page"); return page ?? candidates.at(0) ?? null; }; let chosen = targetId ? resolveById(targetId) : pickDefault(); if ( !chosen && (profile.driver === "extension" || !profile.cdpIsLoopback) && candidates.length === 1 ) { // If an agent passes a stale/foreign targetId but only one candidate remains, // recover by using that tab instead of failing hard. chosen = candidates[0] ?? null; } if (chosen === "AMBIGUOUS") { throw new Error("ambiguous target id prefix"); } if (!chosen) { throw new Error("tab not found"); } profileState.lastTargetId = chosen.targetId; return chosen; }; const resolveTargetIdOrThrow = async (targetId: string): Promise => { const tabs = await listTabs(); const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { if (resolved.reason === "ambiguous") { throw new Error("ambiguous target id prefix"); } throw new Error("tab not found"); } return resolved.targetId; }; const focusTab = async (targetId: string): Promise => { const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const focusPageByTargetIdViaPlaywright = (mod as Partial | null) ?.focusPageByTargetIdViaPlaywright; if (typeof focusPageByTargetIdViaPlaywright === "function") { await focusPageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, }); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; return; } } await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`)); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; }; const closeTab = async (targetId: string): Promise => { const resolvedTargetId = await resolveTargetIdOrThrow(targetId); // For remote profiles, use Playwright's persistent connection to close tabs if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const closePageByTargetIdViaPlaywright = (mod as Partial | null) ?.closePageByTargetIdViaPlaywright; if (typeof closePageByTargetIdViaPlaywright === "function") { await closePageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, }); return; } } await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`)); }; const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { if (profile.driver === "extension") { const stopped = await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl, }); return { stopped }; } const profileState = getProfileState(); if (!profileState.running) { return { stopped: false }; } await stopOpenClawChrome(profileState.running); setProfileRunning(null); return { stopped: true }; }; const resetProfile = async () => { if (profile.driver === "extension") { await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); return { moved: false, from: profile.cdpUrl }; } if (!profile.cdpIsLoopback) { throw new Error( `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, ); } const userDataDir = resolveOpenClawUserDataDir(profile.name); const profileState = getProfileState(); const httpReachable = await isHttpReachable(300); if (httpReachable && !profileState.running) { // Port in use but not by us - kill it try { const mod = await import("./pw-ai.js"); await mod.closePlaywrightBrowserConnection(); } catch { // ignore } } if (profileState.running) { await stopRunningBrowser(); } try { const mod = await import("./pw-ai.js"); await mod.closePlaywrightBrowserConnection(); } catch { // ignore } if (!fs.existsSync(userDataDir)) { return { moved: false, from: userDataDir }; } const moved = await movePathToTrash(userDataDir); return { moved: true, from: userDataDir, to: moved }; }; return { profile, ensureBrowserAvailable, ensureTabAvailable, isHttpReachable, isReachable, listTabs, openTab, focusTab, closeTab, stopRunningBrowser, resetProfile, }; } export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext { const refreshConfigFromDisk = opts.refreshConfigFromDisk === true; const state = () => { const current = opts.getState(); if (!current) { throw new Error("Browser server not started"); } return current; }; const forProfile = (profileName?: string): ProfileContext => { const current = state(); const name = profileName ?? current.resolved.defaultProfile; const profile = resolveBrowserProfileWithHotReload({ current, refreshConfigFromDisk, name, }); if (!profile) { const available = Object.keys(current.resolved.profiles).join(", "); throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`); } return createProfileContext(opts, profile); }; const listProfiles = async (): Promise => { const current = state(); refreshResolvedBrowserConfigFromDisk({ current, refreshConfigFromDisk, mode: "cached", }); const result: ProfileStatus[] = []; for (const name of Object.keys(current.resolved.profiles)) { const profileState = current.profiles.get(name); const profile = resolveProfile(current.resolved, name); if (!profile) { continue; } let tabCount = 0; let running = false; if (profileState?.running) { running = true; try { const ctx = createProfileContext(opts, profile); const tabs = await ctx.listTabs(); tabCount = tabs.filter((t) => t.type === "page").length; } catch { // Browser might not be responsive } } else { // Check if something is listening on the port try { const reachable = await isChromeReachable(profile.cdpUrl, 200); if (reachable) { running = true; const ctx = createProfileContext(opts, profile); const tabs = await ctx.listTabs().catch(() => []); tabCount = tabs.filter((t) => t.type === "page").length; } } catch { // Not reachable } } result.push({ name, cdpPort: profile.cdpPort, cdpUrl: profile.cdpUrl, color: profile.color, running, tabCount, isDefault: name === current.resolved.defaultProfile, isRemote: !profile.cdpIsLoopback, }); } return result; }; // Create default profile context for backward compatibility const getDefaultContext = () => forProfile(); const mapTabError = (err: unknown) => { if (err instanceof SsrFBlockedError) { return { status: 400, message: err.message }; } if (err instanceof InvalidBrowserNavigationUrlError) { return { status: 400, message: err.message }; } const msg = String(err); if (msg.includes("ambiguous target id prefix")) { return { status: 409, message: "ambiguous target id prefix" }; } if (msg.includes("tab not found")) { return { status: 404, message: msg }; } if (msg.includes("not found")) { return { status: 404, message: msg }; } return null; }; return { state, forProfile, listProfiles, // Legacy methods delegate to default profile ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(), ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId), isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs), isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs), listTabs: () => getDefaultContext().listTabs(), openTab: (url) => getDefaultContext().openTab(url), focusTab: (targetId) => getDefaultContext().focusTab(targetId), closeTab: (targetId) => getDefaultContext().closeTab(targetId), stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(), resetProfile: () => getDefaultContext().resetProfile(), mapTabError, }; }