mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 15:20:23 +00:00
fix(browser): add browser session selection
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user