diff --git a/docs/tools/browser.md b/docs/tools/browser.md index dea5e915ff3..ff11d7085f5 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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 user’s 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: diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 5f35077fa98..54833b27e1e 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -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" }); diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 96f82389303..c2d8c41303e 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -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 => 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 => 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="".`, + `Multiple user-browser profiles available. Pick one with profile="" 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="" 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= 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.", diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index 25ae39b2293..c649fe53633 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise 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)}`, ); } diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index ddaee1bb365..7c3dfbfa462 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -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")!; diff --git a/src/browser/config.ts b/src/browser/config.ts index 898980de681..00818c4df86 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -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, - controlPort: number, ): Record { 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"; diff --git a/src/browser/constants.ts b/src/browser/constants.ts index 952bf9190a5..48d230b73da 100644 --- a/src/browser/constants.ts +++ b/src/browser/constants.ts @@ -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"; diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index b736a77d943..6d75f655f69 100644 --- a/src/browser/profile-capabilities.ts +++ b/src/browser/profile-capabilities.ts @@ -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, diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts index 493fbcdfbad..77d2cfceb2c 100644 --- a/src/browser/routes/agent.snapshot.plan.test.ts +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -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, @@ -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", () => {