diff --git a/tests/tools/test_browser_real_profile.py b/tests/tools/test_browser_real_profile.py index cb5bed6659..a181469197 100644 --- a/tests/tools/test_browser_real_profile.py +++ b/tests/tools/test_browser_real_profile.py @@ -248,14 +248,22 @@ class TestRealProfileCdpLaunch: assert cdp == "http://127.0.0.1:41000" self._reset() - def test_launch_never_passes_headless(self, tmp_path): - """--headless would use a separate cookie store → 0 real cookies. + def test_launch_is_headless_and_agent_browser_attaches(self, tmp_path): + """Real-profile browsing runs headless (no focus-stealing window). - We launch the REAL Chrome binary - ourselves (no mock-keychain switches — those break macOS cookie - decryption) and agent-browser attaches via --cdp. So the agent-browser - argv must contain --cdp (attach, not launch) and must NOT contain - --headless or --profile (launch-mode switches). + Two argv paths are checked: + + 1. The REAL Chrome binary we launch ourselves (via Popen) MUST pass + ``--headless=new``. Real-profile browsing is a background + capability — a visible window that grabs focus every turn defeats + the point. NEW headless shares the profile's normal cookie store + (unlike legacy ``--headless``), and cookie decryption is unaffected + by headless — the drop we avoid comes from ``--use-mock-keychain``, + not from headless. We launch without mock-keychain switches, so the + copied auth/login state still loads. + 2. agent-browser ATTACHES to that running Chrome via ``--cdp``, so its + argv must contain ``--cdp`` and must NOT contain launch-mode + switches (``--headless`` / ``--profile``). """ import tools.browser_tool as bt self._reset() @@ -271,6 +279,7 @@ class TestRealProfileCdpLaunch: return None def fake_popen(argv, **kw): + captured["chrome_argv"] = argv (tmp_path / "DevToolsActivePort").write_text("41000\n/devtools/browser/x\n") return FakeChrome() @@ -285,6 +294,9 @@ class TestRealProfileCdpLaunch: patch.object(bt.subprocess, "run", side_effect=fake_run), \ patch.object(bt, "_is_headed_mode", return_value=False): bt._real_profile_cdp() + # The chrome launch itself is headless (no window, no focus steal). + assert "--headless=new" in captured["chrome_argv"] + # agent-browser attaches, it does not launch. assert "--headless" not in captured["argv"] assert "--profile" not in captured["argv"] assert "--cdp" in captured["argv"] diff --git a/tools/browser_tool.py b/tools/browser_tool.py index cd30328c88..866f8bb78d 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1714,13 +1714,27 @@ def _real_profile_cdp() -> tuple: "--disable-features=Translate", "--no-startup-window", ] - if sys.platform.startswith("linux") and not ( + # Drive the copy headlessly by default. Real-profile browsing is a + # background capability — the agent tweets / fills forms / scrapes on + # the user's behalf while they keep working; a visible window that + # steals focus every turn defeats the point. Chrome's NEW headless mode + # shares the profile's normal cookie store (unlike legacy --headless + # with its separate store), so the copied auth/login state still loads. + # Cookie decryption is unaffected by headless: the drop we guard against + # comes from --use-mock-keychain (which agent-browser's own launcher + # force-adds and we deliberately avoid), NOT from headless mode. This + # also covers display-less Linux (servers, CI), where a headed launch + # would exit at startup. Users who want to watch can opt in via the same + # browser.headed / AGENT_BROWSER_HEADED toggle the rest of the browser + # stack honors; on a display-less host we force headless regardless so + # the launch doesn't die. + _has_display = bool( os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") - ): - # Display-less Linux (servers, CI): the real binary cannot open a - # window, so it exits at startup. Chrome's NEW headless mode shares - # the profile's normal cookie store (unlike legacy --headless with - # its separate store), so real-profile auth still loads. + ) + _want_headed = _is_headed_mode() and ( + _has_display or not sys.platform.startswith("linux") + ) + if not _want_headed: chrome_argv.append("--headless=new") try: chrome_proc = subprocess.Popen( diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 628521ed1f..43ed893092 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -187,6 +187,16 @@ re-synced from your real profile whenever a fresh session is launched, so logins you do in your own browser show up in the agent's session. Only the active profile is copied — other Chrome profiles are never snapshotted. +The snapshot browser runs **headless** — it drives your profile in the +background with no visible window and never steals focus, so you can keep +working while the agent tweets, fills forms, or scrapes on your behalf. +(Headless here uses Chrome's *new* headless mode, which reads your normal +cookie store, so your logins still load.) If you'd rather watch it work, the +same [headed-mode](#headed-mode-visible-browser-window) toggle applies — +`browser.headed: true` (or `AGENT_BROWSER_HEADED=1`) opens a visible window for +real-profile browsing too. On a display-less host (servers, CI) it always runs +headless regardless. + If your browser has several profiles (say a work profile and a personal one) and you don't want "whichever profile you touched last" deciding the agent's identity, pin the snapshot source explicitly: