Files
openclaw/src/browser/server-context.tab-ops.ts

224 lines
7.0 KiB
TypeScript

import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } 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 cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
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(cdpHttpBase, "/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(cdpHttpBase, `/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(cdpHttpBase, "/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, CDP_JSON_NEW_TIMEOUT_MS, {
method: "PUT",
}).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(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,
};
}