From 663c1858b80edee2e060047d03cf30a1826363fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 15:41:58 +0000 Subject: [PATCH] refactor(browser): split server context and unify CDP transport --- src/agents/tools/browser-tool.ts | 32 +- src/browser/cdp-proxy-bypass.test.ts | 43 +- src/browser/cdp-proxy-bypass.ts | 51 ++- src/browser/cdp.helpers.ts | 45 +- src/browser/chrome.ts | 26 +- src/browser/pw-session.ts | 4 +- src/browser/server-context.availability.ts | 233 ++++++++++ src/browser/server-context.constants.ts | 9 + .../server-context.remote-tab-ops.test.ts | 282 +----------- src/browser/server-context.tab-ops.ts | 220 ++++++++++ src/browser/server-context.ts | 405 +----------------- 11 files changed, 643 insertions(+), 707 deletions(-) create mode 100644 src/browser/server-context.availability.ts create mode 100644 src/browser/server-context.constants.ts create mode 100644 src/browser/server-context.tab-ops.ts diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index d3cd3cd5c..74f629b2a 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -138,6 +138,26 @@ function readActRequestParam(params: Record) { return request as Parameters[1]; } +function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean { + if (profile !== "chrome") { + return false; + } + const msg = String(err); + return msg.includes("404:") && msg.includes("tab not found"); +} + +function stripTargetIdFromActRequest( + request: Parameters[1], +): Parameters[1] | null { + const targetId = typeof request.targetId === "string" ? request.targetId.trim() : undefined; + if (!targetId) { + return null; + } + const retryRequest = { ...request }; + delete retryRequest.targetId; + return retryRequest as Parameters[1]; +} + type BrowserProxyFile = { path: string; base64: string; @@ -860,15 +880,11 @@ export function createBrowserTool(opts?: { }); return jsonResult(result); } catch (err) { - const msg = String(err); - if (msg.includes("404:") && msg.includes("tab not found") && profile === "chrome") { - const targetId = - typeof request.targetId === "string" ? request.targetId.trim() : undefined; + if (isChromeStaleTargetError(profile, err)) { + const retryRequest = stripTargetIdFromActRequest(request); // Some Chrome relay targetIds can go stale between snapshots and actions. // Retry once without targetId to let relay use the currently attached tab. - if (targetId) { - const retryRequest = { ...request }; - delete retryRequest.targetId; + if (retryRequest) { try { const retryResult = proxyRequest ? await proxyRequest({ @@ -877,7 +893,7 @@ export function createBrowserTool(opts?: { profile, body: retryRequest, }) - : await browserAct(baseUrl, retryRequest as Parameters[1], { + : await browserAct(baseUrl, retryRequest, { profile, }); return jsonResult(retryResult); diff --git a/src/browser/cdp-proxy-bypass.test.ts b/src/browser/cdp-proxy-bypass.test.ts index 5fcd8fe76..184000539 100644 --- a/src/browser/cdp-proxy-bypass.test.ts +++ b/src/browser/cdp-proxy-bypass.test.ts @@ -1,7 +1,12 @@ import http from "node:http"; import https from "node:https"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getDirectAgentForCdp, hasProxyEnv, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; +import { + getDirectAgentForCdp, + hasProxyEnv, + withNoProxyForCdpUrl, + withNoProxyForLocalhost, +} from "./cdp-proxy-bypass.js"; describe("cdp-proxy-bypass", () => { describe("getDirectAgentForCdp", () => { @@ -280,3 +285,39 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => { } }); }); + +describe("withNoProxyForCdpUrl", () => { + it("does not mutate NO_PROXY for non-loopback CDP URLs", async () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + try { + await withNoProxyForCdpUrl("https://browserless.example/chrome?token=abc", async () => { + expect(process.env.NO_PROXY).toBeUndefined(); + expect(process.env.no_proxy).toBeUndefined(); + }); + } finally { + delete process.env.HTTP_PROXY; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + } + }); + + it("does not overwrite external NO_PROXY changes made during execution", async () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + try { + await withNoProxyForCdpUrl("http://127.0.0.1:9222", async () => { + process.env.NO_PROXY = "externally-set"; + process.env.no_proxy = "externally-set"; + }); + expect(process.env.NO_PROXY).toBe("externally-set"); + expect(process.env.no_proxy).toBe("externally-set"); + } finally { + delete process.env.HTTP_PROXY; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + } + }); +}); diff --git a/src/browser/cdp-proxy-bypass.ts b/src/browser/cdp-proxy-bypass.ts index 270a2d69a..6c607ab8e 100644 --- a/src/browser/cdp-proxy-bypass.ts +++ b/src/browser/cdp-proxy-bypass.ts @@ -61,6 +61,7 @@ export function hasProxyEnv(): boolean { let noProxyRefCount = 0; let savedNoProxy: string | undefined; let savedNoProxyLower: string | undefined; +let appliedNoProxy: string | undefined; const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]"; let noProxyDidModify = false; @@ -73,8 +74,27 @@ function noProxyAlreadyCoversLocalhost(): boolean { } export async function withNoProxyForLocalhost(fn: () => Promise): Promise { - if (!hasProxyEnv()) { - return fn(); + return await withNoProxyForCdpUrl("http://127.0.0.1", fn); +} + +function isLoopbackCdpUrl(url: string): boolean { + try { + return isLoopbackHost(new URL(url).hostname); + } catch { + return false; + } +} + +/** + * Scoped NO_PROXY bypass for loopback CDP URLs. + * + * This wrapper only mutates env vars for loopback destinations. On restore, + * it avoids clobbering external NO_PROXY changes that happened while calls + * were in-flight. + */ +export async function withNoProxyForCdpUrl(url: string, fn: () => Promise): Promise { + if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) { + return await fn(); } const isFirst = noProxyRefCount === 0; @@ -87,6 +107,7 @@ export async function withNoProxyForLocalhost(fn: () => Promise): Promise< const extended = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES; process.env.NO_PROXY = extended; process.env.no_proxy = extended; + appliedNoProxy = extended; noProxyDidModify = true; } @@ -95,18 +116,26 @@ export async function withNoProxyForLocalhost(fn: () => Promise): Promise< } finally { noProxyRefCount--; if (noProxyRefCount === 0 && noProxyDidModify) { - if (savedNoProxy !== undefined) { - process.env.NO_PROXY = savedNoProxy; - } else { - delete process.env.NO_PROXY; - } - if (savedNoProxyLower !== undefined) { - process.env.no_proxy = savedNoProxyLower; - } else { - delete process.env.no_proxy; + const currentNoProxy = process.env.NO_PROXY; + const currentNoProxyLower = process.env.no_proxy; + const untouched = + currentNoProxy === appliedNoProxy && + (currentNoProxyLower === appliedNoProxy || currentNoProxyLower === undefined); + if (untouched) { + if (savedNoProxy !== undefined) { + process.env.NO_PROXY = savedNoProxy; + } else { + delete process.env.NO_PROXY; + } + if (savedNoProxyLower !== undefined) { + process.env.no_proxy = savedNoProxyLower; + } else { + delete process.env.no_proxy; + } } savedNoProxy = undefined; savedNoProxyLower = undefined; + appliedNoProxy = undefined; noProxyDidModify = false; } } diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 90fa23286..15276100f 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -1,7 +1,7 @@ import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; -import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; +import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -114,17 +114,20 @@ function createCdpSender(ws: WebSocket) { } export async function fetchJson(url: string, timeoutMs = 1500, init?: RequestInit): Promise { - const res = await fetchChecked(url, timeoutMs, init); + const res = await fetchCdpChecked(url, timeoutMs, init); return (await res.json()) as T; } -async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit): Promise { +export async function fetchCdpChecked( + url: string, + timeoutMs = 1500, + init?: RequestInit, +): Promise { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); - // Bypass proxy for loopback CDP connections (#31219) - const res = await withNoProxyForLocalhost(() => + const res = await withNoProxyForCdpUrl(url, () => fetch(url, { ...init, headers, signal: ctrl.signal }), ); if (!res.ok) { @@ -137,7 +140,24 @@ async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit): } export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise { - await fetchChecked(url, timeoutMs, init); + await fetchCdpChecked(url, timeoutMs, init); +} + +export function openCdpWebSocket( + wsUrl: string, + opts?: { headers?: Record; handshakeTimeoutMs?: number }, +): WebSocket { + const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); + const handshakeTimeoutMs = + typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) + ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) + : 5000; + const agent = getDirectAgentForCdp(wsUrl); + return new WebSocket(wsUrl, { + handshakeTimeout: handshakeTimeoutMs, + ...(Object.keys(headers).length ? { headers } : {}), + ...(agent ? { agent } : {}), + }); } export async function withCdpSocket( @@ -145,18 +165,7 @@ export async function withCdpSocket( fn: (send: CdpSendFn) => Promise, opts?: { headers?: Record; handshakeTimeoutMs?: number }, ): Promise { - const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); - const handshakeTimeoutMs = - typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) - ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) - : 5000; - // Bypass proxy for loopback CDP connections (#31219) - const agent = getDirectAgentForCdp(wsUrl); - const ws = new WebSocket(wsUrl, { - handshakeTimeout: handshakeTimeoutMs, - ...(Object.keys(headers).length ? { headers } : {}), - ...(agent ? { agent } : {}), - }); + const ws = openCdpWebSocket(wsUrl, opts); const { send, closeWithError } = createCdpSender(ws); const openPromise = new Promise((resolve, reject) => { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 672ddb8f6..5d51b6e59 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -2,13 +2,11 @@ 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 { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; -import { appendCdpPath } from "./cdp.helpers.js"; -import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js"; +import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js"; +import { normalizeCdpWsUrl } from "./cdp.js"; import { type BrowserExecutable, resolveBrowserExecutableForPlatform, @@ -84,16 +82,7 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise - fetch(versionUrl, { - signal: ctrl.signal, - headers: getHeadersWithAuth(versionUrl), - }), - ); - if (!res.ok) { - return null; - } + const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal }); const data = (await res.json()) as ChromeVersion; if (!data || typeof data !== "object") { return null; @@ -120,13 +109,8 @@ export async function getChromeWebSocketUrl( async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise { return await new Promise((resolve) => { - const headers = getHeadersWithAuth(wsUrl); - // Bypass proxy for loopback CDP connections (#31219) - const wsAgent = getDirectAgentForCdp(wsUrl); - const ws = new WebSocket(wsUrl, { - handshakeTimeout: timeoutMs, - ...(Object.keys(headers).length ? { headers } : {}), - ...(wsAgent ? { agent: wsAgent } : {}), + const ws = openCdpWebSocket(wsUrl, { + handshakeTimeoutMs: timeoutMs, }); const timer = setTimeout( () => { diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index a0611105d..073562d1c 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -9,7 +9,7 @@ import type { import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; +import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; @@ -338,7 +338,7 @@ async function connectBrowser(cdpUrl: string): Promise { const endpoint = wsUrl ?? normalized; const headers = getHeadersWithAuth(endpoint); // Bypass proxy for loopback CDP connections (#31219) - const browser = await withNoProxyForLocalhost(() => + const browser = await withNoProxyForCdpUrl(endpoint, () => chromium.connectOverCDP(endpoint, { timeout, headers }), ); const onDisconnected = () => { diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts new file mode 100644 index 000000000..42915efb6 --- /dev/null +++ b/src/browser/server-context.availability.ts @@ -0,0 +1,233 @@ +import { + isChromeCdpReady, + isChromeReachable, + launchOpenClawChrome, + stopOpenClawChrome, +} from "./chrome.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; +import { + CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, + CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, + CDP_READY_AFTER_LAUNCH_POLL_MS, + CDP_READY_AFTER_LAUNCH_WINDOW_MS, +} from "./server-context.constants.js"; +import type { + BrowserServerState, + ContextOptions, + ProfileRuntimeState, +} from "./server-context.types.js"; + +type AvailabilityDeps = { + opts: ContextOptions; + profile: ResolvedBrowserProfile; + state: () => BrowserServerState; + getProfileState: () => ProfileRuntimeState; + setProfileRunning: (running: ProfileRuntimeState["running"]) => void; +}; + +type AvailabilityOps = { + isHttpReachable: (timeoutMs?: number) => Promise; + isReachable: (timeoutMs?: number) => Promise; + ensureBrowserAvailable: () => Promise; + stopRunningBrowser: () => Promise<{ stopped: boolean }>; +}; + +export function createProfileAvailability({ + opts, + profile, + state, + getProfileState, + setProfileRunning, +}: AvailabilityDeps): AvailabilityOps { + 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 waitForCdpReadyAfterLaunch = async (): Promise => { + // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. + // If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port. + const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS; + 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( + CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, + Math.min(CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, remainingMs), + ); + if (await isReachable(attemptTimeoutMs)) { + return; + } + await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS)); + } + throw new Error( + `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, + ); + }; + + 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(); + + 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.`, + ); + } + + 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 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 }; + }; + + return { + isHttpReachable, + isReachable, + ensureBrowserAvailable, + stopRunningBrowser, + }; +} diff --git a/src/browser/server-context.constants.ts b/src/browser/server-context.constants.ts new file mode 100644 index 000000000..9026aba53 --- /dev/null +++ b/src/browser/server-context.constants.ts @@ -0,0 +1,9 @@ +export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8; + +export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000; +export const OPEN_TAB_DISCOVERY_POLL_MS = 100; + +export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000; +export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100; +export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75; +export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250; diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 0173ce9a9..ff9afafe1 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -98,6 +98,24 @@ function createJsonListFetchMock(entries: JsonListEntry[]) { }); } +function makeManagedTab(id: string, ordinal: number): JsonListEntry { + return { + id, + title: String(ordinal), + url: `http://127.0.0.1:300${ordinal}`, + webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`, + type: "page", + }; +} + +function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] { + const oldTabs = Array.from({ length: 8 }, (_, index) => + makeManagedTab(`OLD${index + 1}`, index + 1), + ); + const newTab = makeManagedTab("NEW", 9); + return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab]; +} + describe("browser server-context remote profile tab operations", () => { it("uses profile-level attachOnly when global attachOnly is false", async () => { const state = makeState("openclaw"); @@ -397,71 +415,7 @@ describe("browser server-context tab selection state", () => { it("closes excess managed tabs after opening a new tab", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); - const existingTabs = [ - { - id: "OLD1", - title: "1", - url: "http://127.0.0.1:3001", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1", - type: "page", - }, - { - id: "OLD2", - title: "2", - url: "http://127.0.0.1:3002", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2", - type: "page", - }, - { - id: "OLD3", - title: "3", - url: "http://127.0.0.1:3003", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3", - type: "page", - }, - { - id: "OLD4", - title: "4", - url: "http://127.0.0.1:3004", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4", - type: "page", - }, - { - id: "OLD5", - title: "5", - url: "http://127.0.0.1:3005", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5", - type: "page", - }, - { - id: "OLD6", - title: "6", - url: "http://127.0.0.1:3006", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6", - type: "page", - }, - { - id: "OLD7", - title: "7", - url: "http://127.0.0.1:3007", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7", - type: "page", - }, - { - id: "OLD8", - title: "8", - url: "http://127.0.0.1:3008", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8", - type: "page", - }, - { - id: "NEW", - title: "9", - url: "http://127.0.0.1:3009", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", - type: "page", - }, - ]; + const existingTabs = makeManagedTabsWithNew(); const fetchMock = vi.fn(async (url: unknown) => { const value = String(url); @@ -497,71 +451,7 @@ describe("browser server-context tab selection state", () => { it("never closes the just-opened managed tab during cap cleanup", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); - const existingTabs = [ - { - id: "NEW", - title: "9", - url: "http://127.0.0.1:3009", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", - type: "page", - }, - { - id: "OLD1", - title: "1", - url: "http://127.0.0.1:3001", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1", - type: "page", - }, - { - id: "OLD2", - title: "2", - url: "http://127.0.0.1:3002", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2", - type: "page", - }, - { - id: "OLD3", - title: "3", - url: "http://127.0.0.1:3003", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3", - type: "page", - }, - { - id: "OLD4", - title: "4", - url: "http://127.0.0.1:3004", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4", - type: "page", - }, - { - id: "OLD5", - title: "5", - url: "http://127.0.0.1:3005", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5", - type: "page", - }, - { - id: "OLD6", - title: "6", - url: "http://127.0.0.1:3006", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6", - type: "page", - }, - { - id: "OLD7", - title: "7", - url: "http://127.0.0.1:3007", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7", - type: "page", - }, - { - id: "OLD8", - title: "8", - url: "http://127.0.0.1:3008", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8", - type: "page", - }, - ]; + const existingTabs = makeManagedTabsWithNew({ newFirst: true }); const fetchMock = vi.fn(async (url: unknown) => { const value = String(url); @@ -645,71 +535,7 @@ describe("browser server-context tab selection state", () => { it("does not run managed tab cleanup in attachOnly mode", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); - const existingTabs = [ - { - id: "OLD1", - title: "1", - url: "http://127.0.0.1:3001", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1", - type: "page", - }, - { - id: "OLD2", - title: "2", - url: "http://127.0.0.1:3002", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2", - type: "page", - }, - { - id: "OLD3", - title: "3", - url: "http://127.0.0.1:3003", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3", - type: "page", - }, - { - id: "OLD4", - title: "4", - url: "http://127.0.0.1:3004", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4", - type: "page", - }, - { - id: "OLD5", - title: "5", - url: "http://127.0.0.1:3005", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5", - type: "page", - }, - { - id: "OLD6", - title: "6", - url: "http://127.0.0.1:3006", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6", - type: "page", - }, - { - id: "OLD7", - title: "7", - url: "http://127.0.0.1:3007", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7", - type: "page", - }, - { - id: "OLD8", - title: "8", - url: "http://127.0.0.1:3008", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8", - type: "page", - }, - { - id: "NEW", - title: "9", - url: "http://127.0.0.1:3009", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", - type: "page", - }, - ]; + const existingTabs = makeManagedTabsWithNew(); const fetchMock = vi.fn(async (url: unknown) => { const value = String(url); @@ -739,71 +565,7 @@ describe("browser server-context tab selection state", () => { it("does not block openTab on slow best-effort cleanup closes", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); - const existingTabs = [ - { - id: "OLD1", - title: "1", - url: "http://127.0.0.1:3001", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1", - type: "page", - }, - { - id: "OLD2", - title: "2", - url: "http://127.0.0.1:3002", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2", - type: "page", - }, - { - id: "OLD3", - title: "3", - url: "http://127.0.0.1:3003", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3", - type: "page", - }, - { - id: "OLD4", - title: "4", - url: "http://127.0.0.1:3004", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4", - type: "page", - }, - { - id: "OLD5", - title: "5", - url: "http://127.0.0.1:3005", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5", - type: "page", - }, - { - id: "OLD6", - title: "6", - url: "http://127.0.0.1:3006", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6", - type: "page", - }, - { - id: "OLD7", - title: "7", - url: "http://127.0.0.1:3007", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7", - type: "page", - }, - { - id: "OLD8", - title: "8", - url: "http://127.0.0.1:3008", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8", - type: "page", - }, - { - id: "NEW", - title: "9", - url: "http://127.0.0.1:3009", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", - type: "page", - }, - ]; + const existingTabs = makeManagedTabsWithNew(); const fetchMock = vi.fn(async (url: unknown) => { const value = String(url); diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts new file mode 100644 index 000000000..b1c307369 --- /dev/null +++ b/src/browser/server-context.tab-ops.ts @@ -0,0 +1,220 @@ +import { fetchJson, fetchOk } from "./cdp.helpers.js"; +import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; +import type { PwAiModule } from "./pw-ai-module.js"; +import { getPwAiModule } from "./pw-ai-module.js"; +import { + MANAGED_BROWSER_PAGE_TAB_LIMIT, + OPEN_TAB_DISCOVERY_POLL_MS, + OPEN_TAB_DISCOVERY_WINDOW_MS, +} from "./server-context.constants.js"; +import type { + BrowserServerState, + BrowserTab, + ProfileRuntimeState, +} from "./server-context.types.js"; + +type TabOpsDeps = { + profile: ResolvedBrowserProfile; + state: () => BrowserServerState; + getProfileState: () => ProfileRuntimeState; +}; + +type ProfileTabOps = { + listTabs: () => Promise; + openTab: (url: string) => Promise; +}; + +/** + * 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; + } +} + +type CdpTarget = { + id?: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; + type?: string; +}; + +export function createProfileTabOps({ + profile, + state, + getProfileState, +}: TabOpsDeps): ProfileTabOps { + 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 => { + const profileState = getProfileState(); + if ( + profile.driver !== "openclaw" || + !profile.cdpIsLoopback || + state().resolved.attachOnly || + !profileState.running + ) { + return; + } + + const pageTabs = await listTabs() + .then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page")) + .catch(() => [] as BrowserTab[]); + if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) { + return; + } + + const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); + const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; + for (const tab of candidates.slice(0, excessCount)) { + void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { + // best-effort cleanup only + }); + } + }; + + const triggerManagedTabLimit = (keepTargetId: string): void => { + void enforceManagedTabLimit(keepTargetId).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; + triggerManagedTabLimit(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() + OPEN_TAB_DISCOVERY_WINDOW_MS; + 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 }); + triggerManagedTabLimit(found.targetId); + return found; + } + await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS)); + } + triggerManagedTabLimit(createdViaCdp); + return { targetId: createdViaCdp, title: "", url, type: "page" }; + } + + const encoded = encodeURIComponent(url); + 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 }); + triggerManagedTabLimit(created.id); + return { + targetId: created.id, + title: created.title ?? "", + url: resolvedUrl, + wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), + type: created.type, + }; + }; + + return { + listTabs, + openTab, + }; +} diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 4cffd0be4..9bf74f456 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -1,32 +1,20 @@ 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 { fetchOk } from "./cdp.helpers.js"; +import { appendCdpPath } from "./cdp.js"; +import { isChromeReachable, resolveOpenClawUserDataDir } 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 { stopChromeExtensionRelayServer } from "./extension-relay.js"; +import { InvalidBrowserNavigationUrlError } 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 { createProfileAvailability } from "./server-context.availability.js"; +import { createProfileTabOps } from "./server-context.tab-ops.js"; import type { BrowserServerState, BrowserRouteContext, @@ -56,22 +44,6 @@ export function listKnownProfileNames(state: BrowserServerState): string[] { 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. */ @@ -102,343 +74,20 @@ function createProfileContext( 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 { listTabs, openTab } = createProfileTabOps({ + profile, + state, + getProfileState, + }); - 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 => { - const profileState = getProfileState(); - if ( - profile.driver !== "openclaw" || - !profile.cdpIsLoopback || - state().resolved.attachOnly || - !profileState.running - ) { - return; - } - - const pageTabs = await listTabs() - .then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page")) - .catch(() => [] as BrowserTab[]); - 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)) { - void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { - // best-effort cleanup only - }); - } - }; - - const triggerManagedTabLimit = (keepTargetId: string): void => { - void enforceManagedTabLimit(keepTargetId).catch(() => { - // best-effort cleanup only + const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } = + createProfileAvailability({ + opts, + profile, + state, + getProfileState, + setProfileRunning, }); - }; - - 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; - triggerManagedTabLimit(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 }); - triggerManagedTabLimit(found.targetId); - return found; - } - await new Promise((r) => setTimeout(r, 100)); - } - triggerManagedTabLimit(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 }); - triggerManagedTabLimit(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(); @@ -560,22 +209,6 @@ function createProfileContext( 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(() => {});