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:
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user