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 ### 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. - 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. - 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. - 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 RoleRefMap,
type RoleSnapshotOptions, type RoleSnapshotOptions,
} from "./pw-role-snapshot.js"; } from "./pw-role-snapshot.js";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type ChromeMcpSnapshotNode = { export type ChromeMcpSnapshotNode = {
id?: string; id?: string;
@ -14,60 +15,6 @@ export type ChromeMcpSnapshotNode = {
children?: 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 { function normalizeRole(node: ChromeMcpSnapshotNode): string {
const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : ""; const role = typeof node.role === "string" ? node.role.trim().toLowerCase() : "";
return role || "generic"; return role || "generic";

View File

@ -190,6 +190,66 @@ describe("chrome MCP page parsing", () => {
expect(result).toBe(123); 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 () => { it("clears failed pending sessions so the next call can retry", async () => {
let factoryCalls = 0; let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => { const factory: ChromeMcpSessionFactory = async () => {

View File

@ -248,20 +248,24 @@ async function callTool(
args: Record<string, unknown> = {}, args: Record<string, unknown> = {},
): Promise<ChromeMcpToolResult> { ): Promise<ChromeMcpToolResult> {
const session = await getSession(profileName); const session = await getSession(profileName);
let result: ChromeMcpToolResult;
try { try {
const result = (await session.client.callTool({ result = (await session.client.callTool({
name, name,
arguments: args, arguments: args,
})) as ChromeMcpToolResult; })) as ChromeMcpToolResult;
if (result.isError) {
throw new Error(extractToolErrorMessage(result, name));
}
return result;
} catch (err) { } catch (err) {
// Transport/connection error — tear down session so it reconnects on next call
sessions.delete(profileName); sessions.delete(profileName);
await session.client.close().catch(() => {}); await session.client.close().catch(() => {});
throw err; 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> { async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js"; import { withEnv } from "../test-utils/env.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
describe("browser config", () => { describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => { it("defaults to enabled with loopback defaults and lobster-orange color", () => {
@ -278,6 +279,47 @@ describe("browser config", () => {
expect(resolved.ssrfPolicy).toEqual({}); 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", () => { describe("default profile preference", () => {
it("defaults to openclaw profile when defaultProfile is not configured", () => { it("defaults to openclaw profile when defaultProfile is not configured", () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({

View File

@ -342,6 +342,20 @@ export function resolveProfile(
? "existing-session" ? "existing-session"
: "openclaw"; : "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) { if (rawProfileUrl) {
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname; cdpHost = parsed.parsed.hostname;
@ -361,7 +375,7 @@ export function resolveProfile(
cdpIsLoopback: isLoopbackHost(cdpHost), cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color, color: profile.color,
driver, 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 = { export type BrowserProfileCapabilities = {
mode: BrowserProfileMode; mode: BrowserProfileMode;
isRemote: boolean; isRemote: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
requiresRelay: boolean; requiresRelay: boolean;
requiresAttachedTab: boolean; requiresAttachedTab: boolean;
usesPersistentPlaywright: boolean; usesPersistentPlaywright: boolean;
@ -25,6 +27,7 @@ export function getBrowserProfileCapabilities(
return { return {
mode: "local-extension-relay", mode: "local-extension-relay",
isRemote: false, isRemote: false,
usesChromeMcp: false,
requiresRelay: true, requiresRelay: true,
requiresAttachedTab: true, requiresAttachedTab: true,
usesPersistentPlaywright: false, usesPersistentPlaywright: false,
@ -39,6 +42,7 @@ export function getBrowserProfileCapabilities(
return { return {
mode: "local-existing-session", mode: "local-existing-session",
isRemote: false, isRemote: false,
usesChromeMcp: true,
requiresRelay: false, requiresRelay: false,
requiresAttachedTab: false, requiresAttachedTab: false,
usesPersistentPlaywright: false, usesPersistentPlaywright: false,
@ -53,6 +57,7 @@ export function getBrowserProfileCapabilities(
return { return {
mode: "remote-cdp", mode: "remote-cdp",
isRemote: true, isRemote: true,
usesChromeMcp: false,
requiresRelay: false, requiresRelay: false,
requiresAttachedTab: false, requiresAttachedTab: false,
usesPersistentPlaywright: true, usesPersistentPlaywright: true,
@ -66,6 +71,7 @@ export function getBrowserProfileCapabilities(
return { return {
mode: "local-managed", mode: "local-managed",
isRemote: false, isRemote: false,
usesChromeMcp: false,
requiresRelay: false, requiresRelay: false,
requiresAttachedTab: false, requiresAttachedTab: false,
usesPersistentPlaywright: false, usesPersistentPlaywright: false,

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type RoleRef = { export type RoleRef = {
role: string; role: string;
name?: string; name?: string;
@ -23,60 +25,6 @@ export type RoleSnapshotOptions = {
compact?: boolean; 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 { export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length; const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return { return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js"; import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { import {
refreshResolvedBrowserConfigFromDisk, refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload, resolveBrowserProfileWithHotReload,
@ -164,7 +165,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
let running = false; let running = false;
const profileCtx = createProfileContext(opts, profile); const profileCtx = createProfileContext(opts, profile);
if (profile.driver === "existing-session") { if (getBrowserProfileCapabilities(profile).usesChromeMcp) {
try { try {
running = await profileCtx.isReachable(300); running = await profileCtx.isReachable(300);
if (running) { 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, color: HexColorSchema,
}) })
.strict() .strict()
.refine((value) => value.cdpPort || value.cdpUrl, { .refine(
message: "Profile must set cdpPort or cdpUrl", (value) => value.driver === "existing-session" || value.cdpPort || value.cdpUrl,
}), {
message: "Profile must set cdpPort or cdpUrl",
},
),
) )
.optional(), .optional(),
extraArgs: z.array(z.string()).optional(), extraArgs: z.array(z.string()).optional(),