refactor(browser): split server context and unify CDP transport
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
233
src/browser/server-context.availability.ts
Normal file
233
src/browser/server-context.availability.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
src/browser/server-context.constants.ts
Normal file
9
src/browser/server-context.constants.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
220
src/browser/server-context.tab-ops.ts
Normal file
220
src/browser/server-context.tab-ops.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user