fix(browser): real-profile browsing runs headless — no focus-stealing window

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.
This commit is contained in:
Teknium
2026-08-29 20:32:58 -07:00
parent 21e52c1fd4
commit d5fd2e9366
3 changed files with 49 additions and 13 deletions
+19 -7
View File
@@ -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"]
+20 -6
View File
@@ -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(
@@ -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: