refactor(browser): split server context and unify CDP transport

This commit is contained in:
Peter Steinberger
2026-03-02 15:41:58 +00:00
parent 729ddfd7c8
commit 663c1858b8
11 changed files with 643 additions and 707 deletions

View File

@@ -138,6 +138,26 @@ function readActRequestParam(params: Record<string, unknown>) {
return request as Parameters<typeof browserAct>[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<typeof browserAct>[1],
): Parameters<typeof browserAct>[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<typeof browserAct>[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<typeof browserAct>[1], {
: await browserAct(baseUrl, retryRequest, {
profile,
});
return jsonResult(retryResult);

View File

@@ -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;
}
});
});

View File

@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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<T>(url: string, fn: () => Promise<T>): Promise<T> {
if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) {
return await fn();
}
const isFirst = noProxyRefCount === 0;
@@ -87,6 +107,7 @@ export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): 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<T>(fn: () => Promise<T>): 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;
}
}

View File

@@ -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<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> {
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<Response> {
export async function fetchCdpChecked(
url: string,
timeoutMs = 1500,
init?: RequestInit,
): Promise<Response> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
// 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<void> {
await fetchChecked(url, timeoutMs, init);
await fetchCdpChecked(url, timeoutMs, init);
}
export function openCdpWebSocket(
wsUrl: string,
opts?: { headers?: Record<string, string>; 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<T>(
@@ -145,18 +165,7 @@ export async function withCdpSocket<T>(
fn: (send: CdpSendFn) => Promise<T>,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): Promise<T> {
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<void>((resolve, reject) => {

View File

@@ -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<Chro
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
// Bypass proxy for loopback CDP connections (#31219)
const res = await withNoProxyForLocalhost(() =>
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<boolean> {
return await new Promise<boolean>((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(
() => {

View File

@@ -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<ConnectedBrowser> {
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 = () => {

View File

@@ -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<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
ensureBrowserAvailable: () => Promise<void>;
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<ProfileRuntimeState["running"]>) => {
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<void> => {
// 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<void> => {
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,
};
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
};
/**
* 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<BrowserTab[]> => {
// 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<PwAiModule> | 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<void> => {
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<BrowserTab> => {
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<PwAiModule> | 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<CdpTarget>(endpoint, 1500, {
method: "PUT",
}).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(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,
};
}

View File

@@ -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<BrowserTab[]> => {
// 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<PwAiModule> | 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<void> => {
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<BrowserTab> => {
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<PwAiModule> | 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<CdpTarget>(endpoint, 1500, {
method: "PUT",
}).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(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<ProfileRuntimeState["running"]>) => {
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<void> => {
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<BrowserTab> => {
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(() => {});