import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; import { fetchJson, fetchOk } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; import { MANAGED_BROWSER_PAGE_TAB_LIMIT, OPEN_TAB_DISCOVERY_POLL_MS, OPEN_TAB_DISCOVERY_WINDOW_MS, } from "./server-context.constants.js"; import type { BrowserServerState, BrowserTab, ProfileRuntimeState, } from "./server-context.types.js"; type TabOpsDeps = { profile: ResolvedBrowserProfile; state: () => BrowserServerState; getProfileState: () => ProfileRuntimeState; }; type ProfileTabOps = { listTabs: () => Promise; openTab: (url: string) => Promise; }; /** * Normalize a CDP WebSocket URL to use the correct base URL. */ function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined { if (!raw) { return undefined; } try { return normalizeCdpWsUrl(raw, cdpBaseUrl); } catch { return raw; } } type CdpTarget = { id?: string; title?: string; url?: string; webSocketDebuggerUrl?: string; type?: string; }; export function createProfileTabOps({ profile, state, getProfileState, }: TabOpsDeps): ProfileTabOps { const listTabs = async (): Promise => { // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); return pages.map((p) => ({ targetId: p.targetId, title: p.title, url: p.url, type: p.type, })); } } const raw = await fetchJson< Array<{ id?: string; title?: string; url?: string; webSocketDebuggerUrl?: string; type?: string; }> >(appendCdpPath(profile.cdpUrl, "/json/list")); return raw .map((t) => ({ targetId: t.id ?? "", title: t.title ?? "", url: t.url ?? "", wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl), type: t.type, })) .filter((t) => Boolean(t.targetId)); }; const enforceManagedTabLimit = async (keepTargetId: string): Promise => { const profileState = getProfileState(); if ( profile.driver !== "openclaw" || !profile.cdpIsLoopback || state().resolved.attachOnly || !profileState.running ) { return; } const pageTabs = await listTabs() .then((tabs) => tabs.filter((tab) => (tab.type ?? "page") === "page")) .catch(() => [] as BrowserTab[]); if (pageTabs.length <= MANAGED_BROWSER_PAGE_TAB_LIMIT) { return; } const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; for (const tab of candidates.slice(0, excessCount)) { void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { // best-effort cleanup only }); } }; const triggerManagedTabLimit = (keepTargetId: string): void => { void enforceManagedTabLimit(keepTargetId).catch(() => { // best-effort cleanup only }); }; const openTab = async (url: string): Promise => { const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); // For remote profiles, use Playwright's persistent connection to create tabs // This ensures the tab persists beyond a single request. if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; if (typeof createPageViaPlaywright === "function") { const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url, ...ssrfPolicyOpts, }); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; triggerManagedTabLimit(page.targetId); return { targetId: page.targetId, title: page.title, url: page.url, type: page.type, }; } } const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, ...ssrfPolicyOpts, }) .then((r) => r.targetId) .catch(() => null); if (createdViaCdp) { const profileState = getProfileState(); profileState.lastTargetId = createdViaCdp; const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS; while (Date.now() < deadline) { const tabs = await listTabs().catch(() => [] as BrowserTab[]); const found = tabs.find((t) => t.targetId === createdViaCdp); if (found) { await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); triggerManagedTabLimit(found.targetId); return found; } await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS)); } triggerManagedTabLimit(createdViaCdp); return { targetId: createdViaCdp, title: "", url, type: "page" }; } const encoded = encodeURIComponent(url); const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new")); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const endpoint = endpointUrl.search ? (() => { endpointUrl.searchParams.set("url", url); return endpointUrl.toString(); })() : `${endpointUrl.toString()}?${encoded}`; const created = await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS, { method: "PUT", }).catch(async (err) => { if (String(err).includes("HTTP 405")) { return await fetchJson(endpoint, CDP_JSON_NEW_TIMEOUT_MS); } 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, }; }