fix(browser): add browser session selection

This commit is contained in:
Peter Steinberger
2026-03-14 03:46:34 +00:00
parent b857a8d8bc
commit 5c40c1c78a
19 changed files with 575 additions and 36 deletions

View File

@@ -160,6 +160,8 @@ describe("createOpenClawCodingTools", () => {
it("mentions Chrome extension relay in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/browserSession="agent"/i);
expect(browser.description).toMatch(/browserSession="user"/i);
expect(browser.description).toMatch(/profile="chrome"/i);
});
it("keeps browser tool schema properties after normalization", () => {
@@ -172,6 +174,7 @@ describe("createOpenClawCodingTools", () => {
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.browserSession).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");

View File

@@ -35,6 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
const BROWSER_SESSION_CHOICES = ["agent", "user"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -88,6 +89,7 @@ const BrowserActSchema = Type.Object({
export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
browserSession: optionalStringEnum(BROWSER_SESSION_CHOICES),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),

View File

@@ -54,7 +54,45 @@ const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlPort: 18791,
profiles: {},
defaultProfile: "openclaw",
})),
resolveProfile: vi.fn((resolved: Record<string, unknown>, name: string) => {
const profile = (resolved.profiles as Record<string, Record<string, unknown>> | undefined)?.[
name
];
if (!profile) {
return null;
}
const driver =
profile.driver === "extension"
? "extension"
: profile.driver === "existing-session"
? "existing-session"
: "openclaw";
if (driver === "existing-session") {
return {
name,
driver,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
color: typeof profile.color === "string" ? profile.color : "#FF4500",
attachOnly: true,
};
}
return {
name,
driver,
cdpPort: typeof profile.cdpPort === "number" ? profile.cdpPort : 18792,
cdpUrl: typeof profile.cdpUrl === "string" ? profile.cdpUrl : "http://127.0.0.1:18792",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: typeof profile.color === "string" ? profile.color : "#FF4500",
attachOnly: profile.attachOnly === true,
};
}),
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
@@ -117,9 +155,27 @@ function mockSingleBrowserProxyNode() {
function resetBrowserToolMocks() {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
enabled: true,
controlPort: 18791,
profiles: {},
defaultProfile: "openclaw",
});
nodesUtilsMocks.listNodes.mockResolvedValue([]);
}
function setResolvedBrowserProfiles(
profiles: Record<string, Record<string, unknown>>,
defaultProfile = "openclaw",
) {
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
enabled: true,
controlPort: 18791,
profiles,
defaultProfile,
});
}
function registerBrowserToolAfterEachReset() {
afterEach(() => {
resetBrowserToolMocks();
@@ -131,6 +187,7 @@ async function runSnapshotToolCall(params: {
refs?: "aria" | "dom";
maxChars?: number;
profile?: string;
browserSession?: "agent" | "user";
}) {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "snapshot", ...params });
@@ -243,6 +300,90 @@ describe("browser tool snapshot maxChars", () => {
);
});
it('uses the isolated openclaw profile for browserSession="agent"', async () => {
await runSnapshotToolCall({ browserSession: "agent", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "openclaw",
}),
);
});
it('uses the host user browser for browserSession="user"', async () => {
setResolvedBrowserProfiles({
openclaw: { cdpPort: 18800, color: "#FF4500" },
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
action: "snapshot",
browserSession: "user",
snapshotFormat: "ai",
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome",
}),
);
});
it('uses a sole existing-session profile for browserSession="user"', async () => {
setResolvedBrowserProfiles({
openclaw: { cdpPort: 18800, color: "#FF4500" },
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
action: "snapshot",
browserSession: "user",
snapshotFormat: "ai",
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome-live",
}),
);
});
it('fails when browserSession="user" is ambiguous', async () => {
setResolvedBrowserProfiles({
openclaw: { cdpPort: 18800, color: "#FF4500" },
personal: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
work: { driver: "existing-session", attachOnly: true, color: "#0066CC" },
});
const tool = createBrowserTool();
await expect(
tool.execute?.("call-1", {
action: "snapshot",
browserSession: "user",
snapshotFormat: "ai",
}),
).rejects.toThrow(/Multiple user-browser profiles are configured/);
});
it('rejects browserSession="user" with target="sandbox"', async () => {
setResolvedBrowserProfiles({
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await expect(
tool.execute?.("call-1", {
action: "snapshot",
browserSession: "user",
target: "sandbox",
snapshotFormat: "ai",
}),
).rejects.toThrow(/cannot use the sandbox browser/);
});
it("lets the server choose snapshot format when the user does not request one", async () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" });

View File

@@ -16,8 +16,10 @@ import {
browserStatus,
browserStop,
} from "../../browser/client.js";
import { resolveBrowserConfig } from "../../browser/config.js";
import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js";
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "../../browser/constants.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js";
import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js";
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js";
import {
trackSessionBrowserTab,
@@ -278,6 +280,60 @@ function resolveBrowserBaseUrl(params: {
return undefined;
}
function listUserBrowserProfiles() {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
return Object.keys(resolved.profiles ?? {})
.map((name) => resolveProfile(resolved, name))
.filter((profile): profile is NonNullable<typeof profile> => Boolean(profile))
.filter((profile) => {
const capabilities = getBrowserProfileCapabilities(profile);
return capabilities.requiresRelay || capabilities.usesChromeMcp;
});
}
function resolveBrowserToolProfile(params: {
profile?: string;
browserSession?: "agent" | "user";
}): string | undefined {
if (params.profile) {
return params.profile;
}
if (!params.browserSession) {
return undefined;
}
if (params.browserSession === "agent") {
return DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME;
}
const userProfiles = listUserBrowserProfiles();
const defaultUserProfile = userProfiles.find(
(profile) => profile.name !== DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
);
if (defaultUserProfile?.name === "chrome") {
return defaultUserProfile.name;
}
const chromeRelay = userProfiles.find((profile) => profile.name === "chrome");
if (chromeRelay) {
return chromeRelay.name;
}
if (userProfiles.length === 1) {
return userProfiles[0]?.name;
}
const chromeLive = userProfiles.find((profile) => profile.name === "chrome-live");
if (chromeLive) {
return chromeLive.name;
}
if (userProfiles.length === 0) {
throw new Error(
'No user-browser profile is configured. Use profile="chrome" for the extension relay or create an existing-session profile first.',
);
}
throw new Error(
`Multiple user-browser profiles are configured (${userProfiles.map((profile) => profile.name).join(", ")}). Pass profile="<name>".`,
);
}
export function createBrowserTool(opts?: {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
@@ -291,10 +347,12 @@ export function createBrowserTool(opts?: {
name: "browser",
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="openclaw" for the isolated openclaw-managed browser.',
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).',
'Browser choice: use browserSession="agent" by default for the isolated OpenClaw browser. Use browserSession="user" only when logged-in browser state matters and the user is present to click/approve browser attach prompts.',
'browserSession="user" means the real local user browser on the host, not sandbox/node browsers. If user presence is unclear, ask first.',
'profile remains the explicit override. Use profile="chrome" for Chrome extension relay takeover (existing Chrome tabs). Use profile="openclaw" for the isolated OpenClaw-managed browser.',
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use browserSession="user" and prefer profile="chrome" (do not ask which profile unless ambiguous).',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"Chrome extension relay needs an attached tab: user must click the OpenClaw Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it.",
"User-browser flows need user interaction: Chrome extension relay needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON); existing-session may require approving a browser attach prompt.",
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
@@ -305,13 +363,33 @@ export function createBrowserTool(opts?: {
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const profile = readStringParam(params, "profile");
const browserSession = readStringParam(params, "browserSession") as
| "agent"
| "user"
| undefined;
const profile = resolveBrowserToolProfile({
profile: readStringParam(params, "profile"),
browserSession,
});
const requestedNode = readStringParam(params, "node");
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
}
if (browserSession === "user") {
if (requestedNode || target === "node") {
throw new Error('browserSession="user" only supports the local host browser.');
}
if (target === "sandbox") {
throw new Error(
'browserSession="user" cannot use the sandbox browser; use target="host" or omit target.',
);
}
}
if (!target && !requestedNode && browserSession === "user") {
target = "host";
}
if (!target && !requestedNode && profile === "chrome") {
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.