Files
openclaw/src/browser/server-context.selection.ts
George Zhang eee5d7c6b0 fix(browser): harden existing-session driver validation and session lifecycle (#45682)
* fix(browser): harden existing-session driver validation, session lifecycle, and code quality

Fix config validation rejecting existing-session profiles that lack
cdpPort/cdpUrl (they use Chrome MCP auto-connect instead). Fix callTool
tearing down the MCP session on tool-level errors (element not found,
script error), which caused expensive npx re-spawns. Skip unnecessary
CDP port allocation for existing-session profiles. Remove redundant
ensureChromeMcpAvailable call in isReachable.

Extract shared ARIA role sets (INTERACTIVE_ROLES, CONTENT_ROLES,
STRUCTURAL_ROLES) into snapshot-roles.ts so both the Playwright and
Chrome MCP snapshot paths stay in sync. Add usesChromeMcp capability
flag and replace ~20 scattered driver === "existing-session" string
checks with the centralized flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browser): harden existing-session driver validation and session lifecycle (#45682) (thanks @odysseus0)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 20:21:47 -07:00

173 lines
6.1 KiB
TypeScript

import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
type SelectionDeps = {
profile: ResolvedBrowserProfile;
getProfileState: () => ProfileRuntimeState;
ensureBrowserAvailable: () => Promise<void>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
};
type SelectionOps = {
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
};
export function createProfileSelectionOps({
profile,
getProfileState,
ensureBrowserAvailable,
listTabs,
openTab,
}: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
let tabs1 = await listTabs();
if (tabs1.length === 0) {
if (capabilities.requiresAttachedTab) {
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
// the extension to reconnect and re-announce its attached tabs before failing.
if (profileState.lastTargetId?.trim()) {
const deadlineAt = Date.now() + 3_000;
while (tabs1.length === 0 && Date.now() < deadlineAt) {
await new Promise((resolve) => setTimeout(resolve, 200));
tabs1 = await listTabs();
}
}
if (tabs1.length === 0) {
throw new BrowserTabNotFoundError(
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
);
}
} else {
await openTab("about:blank");
}
}
const tabs = await listTabs();
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
return "AMBIGUOUS" as const;
}
return null;
}
return candidates.find((t) => t.targetId === resolved.targetId) ?? null;
};
const pickDefault = () => {
const last = profileState.lastTargetId?.trim() || "";
const lastResolved = last ? resolveById(last) : null;
if (lastResolved && lastResolved !== "AMBIGUOUS") {
return lastResolved;
}
// Prefer a real page tab first (avoid service workers/background targets).
const page = candidates.find((t) => (t.type ?? "page") === "page");
return page ?? candidates.at(0) ?? null;
};
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new BrowserTargetAmbiguousError();
}
if (!chosen) {
throw new BrowserTabNotFoundError();
}
profileState.lastTargetId = chosen.targetId;
return chosen;
};
const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
return resolved.targetId;
};
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) {
await focusChromeMcpTab(profile.name, resolvedTargetId);
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
return;
}
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.focusPageByTargetIdViaPlaywright;
if (typeof focusPageByTargetIdViaPlaywright === "function") {
await focusPageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
});
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
return;
}
}
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
};
const closeTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (capabilities.usesChromeMcp) {
await closeChromeMcpTab(profile.name, resolvedTargetId);
return;
}
// For remote profiles, use Playwright's persistent connection to close tabs
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.closePageByTargetIdViaPlaywright;
if (typeof closePageByTargetIdViaPlaywright === "function") {
await closePageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
});
return;
}
}
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
};
return {
ensureTabAvailable,
focusTab,
closeTab,
};
}