browser: default user-browser profile to existing-session (Chrome MCP)

This commit is contained in:
George Zhang 2026-03-13 22:26:32 -07:00
parent f1d9fcd407
commit f9aebafa7a
No known key found for this signature in database
9 changed files with 82 additions and 108 deletions

View File

@ -371,11 +371,7 @@ openclaw browser create-profile \
--color "#00AA00"
```
Then in Chrome:
1. Open `chrome://inspect/#remote-debugging`
2. Enable remote debugging
3. Keep Chrome running and approve the connection prompt when OpenClaw attaches
Then keep Chrome (v146+) running. OpenClaw auto-discovers it — no setup needed.
Live attach smoke test:
@ -396,17 +392,15 @@ What success looks like:
What to check if attach does not work:
- Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt
- Chrome is version `146+` and running
- no other tool is already attached to the same Chrome session
Agent use:
- Use `browserSession="user"` when you need the users logged-in browser state.
- If you know the profile name, pass `profile="chrome-live"` (or your custom
existing-session profile).
- Only choose this mode when the user is at the computer to approve the attach
prompt.
- Only choose this mode when the user is at the computer.
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
Notes:

View File

@ -311,10 +311,10 @@ describe("browser tool snapshot maxChars", () => {
);
});
it('uses the host user browser for browserSession="user"', async () => {
it('resolves sole user profile for browserSession="user"', async () => {
setResolvedBrowserProfiles({
openclaw: { cdpPort: 18800, color: "#FF4500" },
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
chrome: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
@ -331,31 +331,11 @@ describe("browser tool snapshot maxChars", () => {
);
});
it('uses a sole existing-session profile for browserSession="user"', async () => {
it('lists available profiles when browserSession="user" is ambiguous', 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" },
chrome: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
});
const tool = createBrowserTool();
@ -365,12 +345,12 @@ describe("browser tool snapshot maxChars", () => {
browserSession: "user",
snapshotFormat: "ai",
}),
).rejects.toThrow(/Multiple user-browser profiles are configured/);
).rejects.toThrow(/Multiple user-browser profiles available.*profile=".*"/s);
});
it('rejects browserSession="user" with target="sandbox"', async () => {
setResolvedBrowserProfiles({
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
chrome: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });

View File

@ -280,18 +280,13 @@ 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;
});
}
/**
* Resolve which browser profile to use for a given browserSession value.
*
* For browserSession="user": if exactly one user-capable profile exists, use
* it. Otherwise return undefined and let the caller list available profiles so
* the model (or user) can pick.
*/
function resolveBrowserToolProfile(params: {
profile?: string;
browserSession?: "agent" | "user";
@ -306,31 +301,24 @@ function resolveBrowserToolProfile(params: {
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;
}
// Find all profiles that connect to the user's real browser.
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const userProfiles = Object.keys(resolved.profiles ?? {})
.map((name) => resolveProfile(resolved, name))
.filter((profile): profile is NonNullable<typeof profile> => Boolean(profile))
.filter((profile) => getBrowserProfileCapabilities(profile).isUserBrowser);
if (userProfiles.length === 1) {
return userProfiles[0]?.name;
}
const chromeLive = userProfiles.find((profile) => profile.name === "chrome-live");
if (chromeLive) {
return chromeLive.name;
return userProfiles[0].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("No user-browser profile found. Set up an existing-session profile first.");
}
// Multiple — tell the model what's available so it can pick or ask the user.
const descriptions = userProfiles.map((p) => ` - profile="${p.name}" (${p.driver})`).join("\n");
throw new Error(
`Multiple user-browser profiles are configured (${userProfiles.map((profile) => profile.name).join(", ")}). Pass profile="<name>".`,
`Multiple user-browser profiles available. Pick one with profile="<name>" or ask the user:\n${descriptions}`,
);
}
@ -347,12 +335,11 @@ 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).",
'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).',
'browserSession="agent" (default): isolated OpenClaw-managed browser — fresh profile, no cookies/logins.',
'browserSession="user": the user\'s real local browser with their logged-in sessions. Use when you need their cookies/auth. Chrome (v146+) must be running. If unsure whether the user is present, ask first.',
'If browserSession="user" can\'t auto-resolve (multiple profiles), the error lists available profiles — pick one with profile="<name>" or ask the user.',
'profile is an explicit override and always takes precedence over browserSession. Use action="profiles" to discover available profiles.',
'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".',
"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.",

View File

@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
await client.close().catch(() => {});
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` +
`Make sure Chrome (v146+) is running. ` +
`Details: ${String(err)}`,
);
}

View File

@ -23,9 +23,10 @@ describe("browser config", () => {
expect(openclaw?.cdpPort).toBe(18800);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:18800");
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(18792);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:18792");
expect(chrome?.driver).toBe("existing-session");
expect(chrome?.cdpPort).toBe(0);
expect(chrome?.cdpUrl).toBe("");
expect(chrome?.attachOnly).toBe(true);
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
@ -35,9 +36,7 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(19004);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19004");
expect(chrome?.driver).toBe("existing-session");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012);
@ -50,9 +49,7 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(19014);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
expect(chrome?.driver).toBe("existing-session");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022);
@ -205,14 +202,16 @@ describe("browser config", () => {
);
});
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
it("does not add the built-in chrome profile if the user already has one", () => {
const resolved = resolveBrowserConfig({
profiles: {
openclaw: { cdpPort: 18792, color: "#FF4500" },
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
},
});
expect(resolveProfile(resolved, "chrome")).toBe(null);
expect(resolved.defaultProfile).toBe("openclaw");
// User's explicit chrome profile is preserved, not overwritten
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:18792");
});
it("defaults extraArgs to empty array when not provided", () => {
@ -303,6 +302,7 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
@ -313,7 +313,7 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "chrome")!;
const extension = resolveProfile(resolved, "relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;

View File

@ -8,13 +8,14 @@ import {
import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
MANAGED_BROWSER_DRIVERS,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
@ -180,31 +181,31 @@ function ensureDefaultProfile(
}
/**
* Ensure a built-in "chrome" profile exists for the Chrome extension relay.
* Ensure a built-in "chrome" profile exists for connecting to the user's
* real Chrome browser.
*
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
* It points at the local relay CDP endpoint (controlPort + 1).
* Uses the existing-session driver (Chrome MCP) which connects via
* Chrome MCP auto-discovers a running Chrome (v146+) via its user data
* directory no CDP port or remote-debugging toggle needed.
*/
function ensureDefaultChromeExtensionProfile(
function ensureDefaultUserBrowserProfile(
profiles: Record<string, BrowserProfileConfig>,
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.chrome) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) {
// Skip when the user already has a profile that connects to their real
// browser (any driver not in MANAGED_BROWSER_DRIVERS).
const hasUserProfile = Object.values(result).some(
(p) => p.driver && !MANAGED_BROWSER_DRIVERS.has(p.driver),
);
if (hasUserProfile) {
return result;
}
result.chrome = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
};
return result;
@ -268,7 +269,7 @@ export function resolveBrowserConfig(
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultChromeExtensionProfile(
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
@ -276,7 +277,6 @@ export function resolveBrowserConfig(
cdpPortRangeStart,
legacyCdpUrl,
),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";

View File

@ -1,3 +1,9 @@
/**
* Drivers that launch/manage a browser instance (sandbox). Any driver not in
* this set attaches to the user's existing browser.
*/
export const MANAGED_BROWSER_DRIVERS = new Set(["openclaw", "clawd"]);
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";

View File

@ -9,6 +9,8 @@ export type BrowserProfileMode =
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Profile attaches to the user's real browser (not a managed sandbox). */
isUserBrowser: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
requiresRelay: boolean;
@ -27,6 +29,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-extension-relay",
isRemote: false,
isUserBrowser: true,
usesChromeMcp: false,
requiresRelay: true,
requiresAttachedTab: true,
@ -42,6 +45,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-existing-session",
isRemote: false,
isUserBrowser: true,
usesChromeMcp: true,
requiresRelay: false,
requiresAttachedTab: false,
@ -57,6 +61,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "remote-cdp",
isRemote: true,
isUserBrowser: false,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,
@ -71,6 +76,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-managed",
isRemote: false,
isUserBrowser: false,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,

View File

@ -3,10 +3,11 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults chrome extension relay snapshots to aria when format is omitted", () => {
it("defaults chrome existing-session snapshots to ai when format is omitted", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "chrome");
expect(profile).toBeTruthy();
expect(profile?.driver).toBe("existing-session");
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
@ -14,7 +15,7 @@ describe("resolveSnapshotPlan", () => {
hasPlaywright: true,
});
expect(plan.format).toBe("aria");
expect(plan.format).toBe("ai");
});
it("keeps ai snapshots for managed browsers when Playwright is available", () => {