mirror of https://github.com/openclaw/openclaw.git
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:
parent
01674c575e
commit
eee5d7c6b0
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]);
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue