From d5fd2e93666d20616dca932a488c64ddace2b917 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:32:58 -0700 Subject: [PATCH] =?UTF-8?q?fix(browser):=20real-profile=20browsing=20runs?= =?UTF-8?q?=20headless=20=E2=80=94=20no=20focus-stealing=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-profile browsing (browser.use_real_profile) is meant to drive a COPY of the user's profile headlessly in the background so they can keep working while the agent acts on their behalf. Instead, on any host with a display it launched the user's real browser binary HEADED, popping a window that grabbed focus on every turn. Root cause: the native launch (which bypasses agent-browser's own launcher to avoid --use-mock-keychain dropping keychain-encrypted cookies) only added --headless=new when Linux had no DISPLAY/WAYLAND_DISPLAY. On a normal desktop the guard was false, so Chrome opened a visible window. Fix: launch headless by default on every platform. Chrome's NEW headless mode shares the profile's normal cookie store (unlike legacy --headless), and the cookie drop we guard against comes from --use-mock-keychain, not from headless — so real-profile auth still loads. Users who want to watch can opt in via the existing browser.headed / AGENT_BROWSER_HEADED toggle (honored for real-profile now, same as the rest of the browser stack); display-less hosts stay headless regardless so the launch doesn't die at startup. Live A/B on a real X seat: old argv mapped a Chrome window (focus steal), --headless=new mapped zero windows while still exposing a working CDP port. Updated the stale test that pinned 'never passes --headless' (a legacy-headless premise) to positively assert the chrome launch is --headless=new by default. --- tests/tools/test_browser_real_profile.py | 26 +++++++++++++++------ tools/browser_tool.py | 26 ++++++++++++++++----- website/docs/user-guide/features/browser.md | 10 ++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) 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: