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>
This commit is contained in:
George Zhang 2026-03-13 20:21:47 -07:00 committed by GitHub
parent 01674c575e
commit eee5d7c6b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 256 additions and 157 deletions

View File

@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
- Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.

View File

@ -4,6 +4,7 @@ import {
type RoleRefMap,
type RoleSnapshotOptions,
} from "./pw-role-snapshot.js";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type ChromeMcpSnapshotNode = {
id?: string;
@ -14,60 +15,6 @@ export type ChromeMcpSnapshotNode = {
children?: ChromeMcpSnapshotNode[];
};
const INTERACTIVE_ROLES = new Set([
"button",
"checkbox",
"combobox",
"link",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"treeitem",
]);
const CONTENT_ROLES = new Set([
"article",
"cell",
"columnheader",
"gridcell",
"heading",
"listitem",
"main",
"navigation",
"region",
"rowheader",
]);
const STRUCTURAL_ROLES = new Set([
"application",
"directory",
"document",
"generic",
"group",
"ignored",
"list",
"menu",
"menubar",
"none",
"presentation",
"row",
"rowgroup",
"tablist",
"table",
"toolbar",
"tree",
"treegrid",
]);
function normalizeRole(node: ChromeMcpSnapshotNode): string {
const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : "";
return role || "generic";

View File

@ -190,6 +190,66 @@ describe("chrome MCP page parsing", () => {
expect(result).toBe(123);
});
it("preserves session after tool-level errors (isError)", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const callTool = vi.fn(async ({ name }: ToolCall) => {
if (name === "evaluate_script") {
return {
content: [{ type: "text", text: "element not found" }],
isError: true,
};
}
if (name === "list_pages") {
return {
content: [{ type: "text", text: "## Pages\n1: https://example.com [selected]" }],
};
}
throw new Error(`unexpected tool ${name}`);
});
session.client.callTool = callTool as typeof session.client.callTool;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
// First call: tool error (isError: true) — should NOT destroy session
await expect(
evaluateChromeMcpScript({ profileName: "chrome-live", targetId: "1", fn: "() => null" }),
).rejects.toThrow(/element not found/);
// Second call: should reuse the same session (factory called only once)
const tabs = await listChromeMcpTabs("chrome-live");
expect(factoryCalls).toBe(1);
expect(tabs).toHaveLength(1);
});
it("destroys session on transport errors so next call reconnects", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
if (factoryCalls === 1) {
// First session: transport error (callTool throws)
const callTool = vi.fn(async () => {
throw new Error("connection reset");
});
session.client.callTool = callTool as typeof session.client.callTool;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
// First call: transport error — should destroy session
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/connection reset/);
// Second call: should create a new session (factory called twice)
const tabs = await listChromeMcpTabs("chrome-live");
expect(factoryCalls).toBe(2);
expect(tabs).toHaveLength(2);
});
it("clears failed pending sessions so the next call can retry", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {

View File

@ -248,20 +248,24 @@ async function callTool(
args: Record<string, unknown> = {},
): Promise<ChromeMcpToolResult> {
const session = await getSession(profileName);
let result: ChromeMcpToolResult;
try {
const result = (await session.client.callTool({
result = (await session.client.callTool({
name,
arguments: args,
})) as ChromeMcpToolResult;
if (result.isError) {
throw new Error(extractToolErrorMessage(result, name));
}
return result;
} catch (err) {
// Transport/connection error — tear down session so it reconnects on next call
sessions.delete(profileName);
await session.client.close().catch(() => {});
throw err;
}
// Tool-level errors (element not found, script error, etc.) don't indicate a
// broken connection — don't tear down the session for these.
if (result.isError) {
throw new Error(extractToolErrorMessage(result, name));
}
return result;
}
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
@ -278,6 +279,47 @@ describe("browser config", () => {
expect(resolved.ssrfPolicy).toEqual({});
});
it("resolves existing-session profiles without cdpPort or cdpUrl", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
},
},
});
const profile = resolveProfile(resolved, "chrome-live");
expect(profile).not.toBeNull();
expect(profile?.driver).toBe("existing-session");
expect(profile?.attachOnly).toBe(true);
expect(profile?.cdpPort).toBe(0);
expect(profile?.cdpUrl).toBe("");
expect(profile?.cdpIsLoopback).toBe(true);
expect(profile?.color).toBe("#00AA00");
});
it("sets usesChromeMcp only for existing-session profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
const existingSession = resolveProfile(resolved, "chrome-live")!;
expect(getBrowserProfileCapabilities(existingSession).usesChromeMcp).toBe(true);
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "chrome")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;
expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false);
});
describe("default profile preference", () => {
it("defaults to openclaw profile when defaultProfile is not configured", () => {
const resolved = resolveBrowserConfig({

View File

@ -342,6 +342,20 @@ export function resolveProfile(
? "existing-session"
: "openclaw";
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
return {
name: profileName,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
color: profile.color,
driver,
attachOnly: true,
};
}
if (rawProfileUrl) {
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname;
@ -361,7 +375,7 @@ export function resolveProfile(
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
attachOnly: driver === "existing-session" ? true : (profile.attachOnly ?? resolved.attachOnly),
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
}

View File

@ -9,6 +9,8 @@ export type BrowserProfileMode =
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
requiresRelay: boolean;
requiresAttachedTab: boolean;
usesPersistentPlaywright: boolean;
@ -25,6 +27,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-extension-relay",
isRemote: false,
usesChromeMcp: false,
requiresRelay: true,
requiresAttachedTab: true,
usesPersistentPlaywright: false,
@ -39,6 +42,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-existing-session",
isRemote: false,
usesChromeMcp: true,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,
@ -53,6 +57,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "remote-cdp",
isRemote: true,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: true,
@ -66,6 +71,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-managed",
isRemote: false,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,

View File

@ -178,10 +178,9 @@ describe("BrowserProfilesService", () => {
driver: "existing-session",
});
expect(result.cdpPort).toBe(18801);
expect(result.cdpPort).toBe(0);
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles["chrome-live"]).toEqual({
cdpPort: 18801,
driver: "existing-session",
attachOnly: true,
color: expect.any(String),
@ -191,7 +190,6 @@ describe("BrowserProfilesService", () => {
browser: expect.objectContaining({
profiles: expect.objectContaining({
"chrome-live": expect.objectContaining({
cdpPort: 18801,
driver: "existing-session",
attachOnly: true,
}),

View File

@ -141,6 +141,14 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
if (driver === "extension") {
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
}
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port needed
profileConfig = {
driver,
attachOnly: true,
color: profileColor,
};
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const range = cdpPortRange(state.resolved);
const cdpPort = allocateCdpPort(usedPorts, range);
@ -150,10 +158,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
profileConfig = {
cdpPort,
...(driver ? { driver } : {}),
...(driver === "existing-session" ? { attachOnly: true } : {}),
color: profileColor,
};
}
}
const nextConfig: OpenClawConfig = {
...cfg,

View File

@ -1,3 +1,5 @@
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type RoleRef = {
role: string;
name?: string;
@ -23,60 +25,6 @@ export type RoleSnapshotOptions = {
compact?: boolean;
};
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem",
]);
const CONTENT_ROLES = new Set([
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation",
]);
const STRUCTURAL_ROLES = new Set([
"generic",
"group",
"list",
"table",
"row",
"rowgroup",
"grid",
"treegrid",
"menu",
"menubar",
"toolbar",
"tablist",
"tree",
"directory",
"document",
"application",
"presentation",
"none",
]);
export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {

View File

@ -1,3 +1,4 @@
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
@ -34,7 +35,7 @@ export function registerBrowserAgentActDownloadRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
@ -88,7 +89,7 @@ export function registerBrowserAgentActDownloadRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,

View File

@ -1,4 +1,5 @@
import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
@ -43,7 +44,7 @@ export function registerBrowserAgentActHookRoutes(
}
const resolvedPaths = uploadPathsResult.paths;
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
@ -123,7 +124,7 @@ export function registerBrowserAgentActHookRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(
res,

View File

@ -11,6 +11,7 @@ import {
} from "../chrome-mcp.js";
import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js";
import { normalizeBrowserFormField } from "../form-fields.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import { matchBrowserUrlPattern } from "../url-pattern.js";
import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js";
@ -477,7 +478,7 @@ export function registerBrowserAgentActRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const isExistingSession = profileCtx.profile.driver === "existing-session";
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
switch (kind) {
@ -1110,7 +1111,7 @@ export function registerBrowserAgentActRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
@ -1147,7 +1148,7 @@ export function registerBrowserAgentActRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
targetId: tab.targetId,

View File

@ -16,6 +16,7 @@ import {
assertBrowserNavigationResultAllowed,
} from "../navigation-guard.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
@ -225,7 +226,7 @@ export function registerBrowserAgentSnapshotRoutes(
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
@ -263,7 +264,7 @@ export function registerBrowserAgentSnapshotRoutes(
if (!profileCtx) {
return;
}
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(
res,
501,
@ -311,7 +312,7 @@ export function registerBrowserAgentSnapshotRoutes(
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(
res,
@ -395,7 +396,7 @@ export function registerBrowserAgentSnapshotRoutes(
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (profileCtx.profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(
res,

View File

@ -1,6 +1,7 @@
import { getChromeMcpPid } from "../chrome-mcp.js";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { toBrowserErrorResponse } from "../errors.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
@ -100,8 +101,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
running: cdpReady,
cdpReady,
cdpHttp,
pid:
profileCtx.profile.driver === "existing-session"
pid: getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: profileCtx.profile.cdpPort,

View File

@ -65,8 +65,8 @@ export function createProfileAvailability({
});
const isReachable = async (timeoutMs?: number) => {
if (profile.driver === "existing-session") {
await ensureChromeMcpAvailable(profile.name);
if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
await listChromeMcpTabs(profile.name);
return true;
}
@ -75,7 +75,7 @@ export function createProfileAvailability({
};
const isHttpReachable = async (timeoutMs?: number) => {
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
return await isReachable(timeoutMs);
}
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
@ -122,7 +122,7 @@ export function createProfileAvailability({
if (previousProfile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
}
if (previousProfile.driver === "existing-session") {
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
await closeChromeMcpSession(previousProfile.name).catch(() => false);
}
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
@ -154,7 +154,7 @@ export function createProfileAvailability({
const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime();
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
await ensureChromeMcpAvailable(profile.name);
return;
}
@ -258,7 +258,7 @@ export function createProfileAvailability({
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
await reconcileProfileRuntime();
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
const stopped = await closeChromeMcpSession(profile.name);
return { stopped };
}

View File

@ -112,7 +112,7 @@ export function createProfileSelectionOps({
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
await focusChromeMcpTab(profile.name, resolvedTargetId);
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
@ -142,7 +142,7 @@ export function createProfileSelectionOps({
const closeTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
await closeChromeMcpTab(profile.name, resolvedTargetId);
return;
}

View File

@ -66,7 +66,7 @@ export function createProfileTabOps({
const capabilities = getBrowserProfileCapabilities(profile);
const listTabs = async (): Promise<BrowserTab[]> => {
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
return await listChromeMcpTabs(profile.name);
}
@ -139,7 +139,7 @@ export function createProfileTabOps({
const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
if (profile.driver === "existing-session") {
if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const page = await openChromeMcpTab(profile.name, url);
const profileState = getProfileState();

View File

@ -4,6 +4,7 @@ import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload,
@ -164,7 +165,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
let running = false;
const profileCtx = createProfileContext(opts, profile);
if (profile.driver === "existing-session") {
if (getBrowserProfileCapabilities(profile).usesChromeMcp) {
try {
running = await profileCtx.isReachable(300);
if (running) {

View File

@ -0,0 +1,63 @@
/**
* Shared ARIA role classification sets used by both the Playwright and Chrome MCP
* snapshot paths. Keep these in sync divergence causes the two drivers to produce
* different snapshot output for the same page.
*/
/** Roles that represent user-interactive elements and always get a ref. */
export const INTERACTIVE_ROLES = new Set([
"button",
"checkbox",
"combobox",
"link",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"treeitem",
]);
/** Roles that carry meaningful content and get a ref when named. */
export const CONTENT_ROLES = new Set([
"article",
"cell",
"columnheader",
"gridcell",
"heading",
"listitem",
"main",
"navigation",
"region",
"rowheader",
]);
/** Structural/container roles — typically skipped in compact mode. */
export const STRUCTURAL_ROLES = new Set([
"application",
"directory",
"document",
"generic",
"grid",
"group",
"ignored",
"list",
"menu",
"menubar",
"none",
"presentation",
"row",
"rowgroup",
"table",
"tablist",
"toolbar",
"tree",
"treegrid",
]);

View File

@ -371,9 +371,12 @@ export const OpenClawSchema = z
color: HexColorSchema,
})
.strict()
.refine((value) => value.cdpPort || value.cdpUrl, {
.refine(
(value) => value.driver === "existing-session" || value.cdpPort || value.cdpUrl,
{
message: "Profile must set cdpPort or cdpUrl",
}),
},
),
)
.optional(),
extraArgs: z.array(z.string()).optional(),