mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 19:10:39 +00:00
224 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|