From 445f21bd9542d65bd09299988d6aa64f82b55603 Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Tue, 2 Jun 2026 14:44:48 -0700 Subject: [PATCH 1/3] feat: BH_NO_ACTIVATE env flag to skip Target.activateTarget in switch_tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new_tab -> switch_tab -> Target.activateTarget raises the tab/window to the OS foreground on every call. When driving a *visible* browser that's the focus-steal users notice on each action. The 🐴 title marker already shows which tab is controlled, and set_session still routes cdp()/js() to the tab without activating it — so activation is purely cosmetic OS focus. Gate it behind an opt-in env flag (BH_NO_ACTIVATE=1) so automated/background drivers can suppress the focus-steal. Default behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/browser_harness/helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index d1dfd37a..ac757826 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -299,7 +299,12 @@ def switch_tab(target): # plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix. try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)") except Exception: pass - cdp("Target.activateTarget", targetId=target_id) + # BH_NO_ACTIVATE=1 skips raising the tab/window to the foreground — activation only + # affects OS focus (the 🐴 title marker already shows the controlled tab) and + # stealing focus on every new_tab is disruptive when driving a visible browser. + # set_session below still routes cdp()/js() to this tab regardless. + if os.environ.get("BH_NO_ACTIVATE") != "1": + cdp("Target.activateTarget", targetId=target_id) sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] _send({"meta": "set_session", "session_id": sid, "target_id": target_id}) _mark_tab() From ff76d8ad165f99e724b09e4b26875263cbd85496 Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Sun, 21 Jun 2026 22:27:21 -0700 Subject: [PATCH 2/3] feat: BH_NO_ACTIVATE also backgrounds Target.createTarget (new_tab + daemon) switch_tab's activateTarget gate alone doesn't stop focus-steal: new_tab() and the daemon's no-real-pages bootstrap call Target.createTarget, which raises the OS window on macOS before switch_tab runs. Pass background:true under BH_NO_ACTIVATE=1 to cover the new_tab path too. Default unchanged. Adds unit tests for the background flag in new_tab and attach_first_page. Co-authored-by: Aaron Iba --- src/browser_harness/daemon.py | 6 +++++- src/browser_harness/helpers.py | 8 +++++++- tests/unit/test_daemon.py | 34 ++++++++++++++++++++++++++++++++++ tests/unit/test_helpers.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index a4d7e00e..04d83fef 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -201,7 +201,11 @@ async def attach_first_page(self): pages = [t for t in targets if is_real_page(t)] if not pages: # No real pages - create one instead of attaching to omnibox popup. - tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"] + # BH_NO_ACTIVATE=1 → background:true so bootstrap doesn't raise the window. + create_params = {"url": "about:blank"} + if os.environ.get("BH_NO_ACTIVATE") == "1": + create_params["background"] = True # Mac-only: don't raise the window. + tid = (await self.cdp.send_raw("Target.createTarget", create_params))["targetId"] log(f"no real pages found, created about:blank ({tid})") pages = [{"targetId": tid, "url": "about:blank", "type": "page"}] self.session = (await self.cdp.send_raw( diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index ac757826..0c026e1a 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -323,7 +323,13 @@ def new_tab(url="about:blank"): return cur.get("targetId") or cur.get("target_id") except Exception: pass - tid = cdp("Target.createTarget", url="about:blank")["targetId"] + # BH_NO_ACTIVATE=1 → background:true so creating the tab doesn't raise the + # OS window. switch_tab's activateTarget gate alone can't stop this: + # Target.createTarget foregrounds the window on macOS before switch runs. + params = {"url": "about:blank"} + if os.environ.get("BH_NO_ACTIVATE") == "1": + params["background"] = True # Mac-only: open the tab without raising the window. + tid = cdp("Target.createTarget", **params)["targetId"] switch_tab(tid) if url != "about:blank": goto_url(url) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 90c5bc85..c319fde1 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -293,3 +293,37 @@ def test_current_tab_meta_returns_not_attached_when_no_target_id(): assert result == {"error": "not_attached"} # No CDP call should have been issued. assert d.cdp.calls == [] + + +# --- BH_NO_ACTIVATE: bootstrap createTarget backgrounding --- + +def _daemon_no_real_pages(): + """Daemon whose CDP reports zero real pages, so attach_first_page() hits + the create-target bootstrap path.""" + d = daemon.Daemon() + class _FakeCDPNoPages: + def __init__(self): self.calls = [] + async def send_raw(self, method, params=None, session_id=None): + self.calls.append((method, params, session_id)) + if method == "Target.getTargets": return {"targetInfos": []} + if method == "Target.createTarget": return {"targetId": "tid-new"} + if method == "Target.attachToTarget": return {"sessionId": "sess-new"} + return {} + d.cdp = _FakeCDPNoPages() + return d + + +def test_attach_first_page_creates_foreground_by_default(monkeypatch): + monkeypatch.delenv("BH_NO_ACTIVATE", raising=False) + d = _daemon_no_real_pages() + asyncio.run(d.attach_first_page()) + create = next(p for m, p, _ in d.cdp.calls if m == "Target.createTarget") + assert "background" not in create + + +def test_attach_first_page_creates_background_when_no_activate(monkeypatch): + monkeypatch.setenv("BH_NO_ACTIVATE", "1") + d = _daemon_no_real_pages() + asyncio.run(d.attach_first_page()) + create = next(p for m, p, _ in d.cdp.calls if m == "Target.createTarget") + assert create.get("background") is True diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 4a45ee07..62f2ac95 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -350,3 +350,37 @@ def fake_send(req): "session filter, the background rWS/lF pair would have updated " "last_activity and prevented the idle window from elapsing." ) + + +# --- BH_NO_ACTIVATE: new_tab opens the tab without raising the OS window --- + +def _record_tab_cdp(monkeypatch, no_activate): + """Patch helpers.cdp + _send, returning the list of (method, kwargs) calls.""" + calls = [] + def fake_cdp(method, **kw): + calls.append((method, kw)) + if method == "Target.attachToTarget": + return {"sessionId": "sid-1"} + if method == "Target.createTarget": + return {"targetId": "tid-1"} + return {} + monkeypatch.setattr(helpers, "cdp", fake_cdp) + monkeypatch.setattr(helpers, "_send", lambda msg: None) + monkeypatch.delenv("BH_NO_ACTIVATE", raising=False) + if no_activate: + monkeypatch.setenv("BH_NO_ACTIVATE", "1") + return calls + + +def test_new_tab_creates_foreground_by_default(monkeypatch): + calls = _record_tab_cdp(monkeypatch, no_activate=False) + helpers.new_tab() + create = next(kw for m, kw in calls if m == "Target.createTarget") + assert "background" not in create + + +def test_new_tab_creates_background_when_no_activate(monkeypatch): + calls = _record_tab_cdp(monkeypatch, no_activate=True) + helpers.new_tab() + create = next(kw for m, kw in calls if m == "Target.createTarget") + assert create.get("background") is True From 929b4dc9e4b48c0df0ddbf58a713d49aaa7f1eab Mon Sep 17 00:00:00 2001 From: Jerry Xiao Date: Sun, 21 Jun 2026 22:47:59 -0700 Subject: [PATCH 3/3] feat: per-call background override, daemon keepalive anchor, DRY flag check - new_tab(url, background=True|False) overrides BH_NO_ACTIVATE per call; switch_tab gains an `activate` override so backgrounding also skips the activateTarget raise (backgrounding createTarget alone doesn't stop it). - daemon bootstrap anchors a real data: page (not about:blank, which Chrome treats as internal) when no real pages exist, so new_tab stays backgrounded without a user-managed sentinel (follow-up suggested in #469). - centralize the env check in _no_activate(). - document BH_NO_ACTIVATE in SKILL.md + install.md. Tests for the per-call override (incl. the activate-skip regression) and the data: anchor; verified end-to-end on Chrome for Testing. --- SKILL.md | 1 + install.md | 2 ++ src/browser_harness/daemon.py | 17 +++++++++------ src/browser_harness/helpers.py | 33 ++++++++++++++++++++--------- tests/unit/test_daemon.py | 9 +------- tests/unit/test_helpers.py | 38 ++++++++++++++++++++++++++++------ 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/SKILL.md b/SKILL.md index ffaef271..94d5ee9f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -79,6 +79,7 @@ Cloud profile cookie sync reference: https://github.com/browser-use/browser-harn - Clicking: screenshot -> read pixel -> `click_at_xy(x, y)` -> screenshot again. - After navigation, call `wait_for_load()`. - If the current tab is stale or internal, call `ensure_real_tab()`. +- Driving a *visible* browser the user is watching? Set `BH_NO_ACTIVATE=1` so `new_tab()`/`switch_tab()` don't raise the window and steal OS focus. Per call: `new_tab(url, background=True|False)` or `switch_tab(target, activate=True|False)`. The daemon auto-anchors a tab, so no manual sentinel needed. - Use `js(...)` for DOM inspection or extraction when coordinates are the wrong tool. - Login walls: stop and ask. Exception: use available SSO automatically when Chrome is already signed in; still stop for passwords, MFA, consent, or ambiguous account choice. - Raw CDP is available with `cdp("Domain.method", ...)`. diff --git a/install.md b/install.md index 52e655f4..3e8e6ab7 100644 --- a/install.md +++ b/install.md @@ -83,4 +83,6 @@ browser-harness --update -y browser-harness telemetry disable ``` +`BH_NO_ACTIVATE=1` stops `new_tab()` / `switch_tab()` from raising the browser window to the OS foreground — useful when driving a visible browser you're watching. Default off. The daemon auto-anchors a real tab so no manual sentinel is needed; per call, override with `new_tab(url, background=True|False)` or `switch_tab(target, activate=True|False)`. + State lives under `${XDG_CONFIG_HOME:-~/.config}/browser-harness` by default: auth, telemetry id, agent workspace, runtime sockets, logs, screenshots, and temp files. Override with `BH_HOME` or `BROWSER_HARNESS_HOME`. diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index 04d83fef..242a30e1 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -201,13 +201,18 @@ async def attach_first_page(self): pages = [t for t in targets if is_real_page(t)] if not pages: # No real pages - create one instead of attaching to omnibox popup. - # BH_NO_ACTIVATE=1 → background:true so bootstrap doesn't raise the window. - create_params = {"url": "about:blank"} - if os.environ.get("BH_NO_ACTIVATE") == "1": - create_params["background"] = True # Mac-only: don't raise the window. + no_activate = os.environ.get("BH_NO_ACTIVATE") == "1" + # BH_NO_ACTIVATE: anchor with a real loaded data: page (not about:blank, + # which Chrome treats as internal) so the window keeps a tab that lets a + # later new_tab(background) stay backgrounded — no user sentinel needed. + # background:true keeps this bootstrap tab from raising the window too. + url = "data:text/html,browser-harness" if no_activate else "about:blank" + create_params = {"url": url} + if no_activate: + create_params["background"] = True # doesn't raise the window (verified macOS) tid = (await self.cdp.send_raw("Target.createTarget", create_params))["targetId"] - log(f"no real pages found, created about:blank ({tid})") - pages = [{"targetId": tid, "url": "about:blank", "type": "page"}] + log(f"no real pages found, created {url[:40]} ({tid})") + pages = [{"targetId": tid, "url": url, "type": "page"}] self.session = (await self.cdp.send_raw( "Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True} ))["sessionId"] diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index 0c026e1a..5dc68b6c 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -291,7 +291,12 @@ def _mark_tab(): try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title") except Exception: pass -def switch_tab(target): +def _no_activate(): + """True when BH_NO_ACTIVATE=1 — suppress raising the OS window on tab ops.""" + return os.environ.get("BH_NO_ACTIVATE") == "1" + + +def switch_tab(target, activate=None): # Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(), # so `switch_tab(current_tab())` works without a manual ["targetId"] dance. target_id = (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target @@ -303,18 +308,24 @@ def switch_tab(target): # affects OS focus (the 🐴 title marker already shows the controlled tab) and # stealing focus on every new_tab is disruptive when driving a visible browser. # set_session below still routes cdp()/js() to this tab regardless. - if os.environ.get("BH_NO_ACTIVATE") != "1": + # activate None → follow BH_NO_ACTIVATE; True/False overrides per call so + # new_tab(background=...) can suppress the raise without the env flag. + do_activate = (not _no_activate()) if activate is None else activate + if do_activate: cdp("Target.activateTarget", targetId=target_id) sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] _send({"meta": "set_session", "session_id": sid, "target_id": target_id}) _mark_tab() return sid -def new_tab(url="about:blank"): +def new_tab(url="about:blank", background=None): # Always create blank, then goto: passing url to createTarget races with # attach, so the brief about:blank is "complete" by the time the caller # polls and wait_for_load() returns before navigation actually starts. - if url != "about:blank": + # Reuse the current blank tab only without an explicit background override — + # an explicit background=… wants a fresh tab with that activation behavior, + # which this reuse path (no createTarget/switch_tab) can't honor. + if url != "about:blank" and background is None: try: cur = current_tab() cur_url = cur.get("url") or "" @@ -323,14 +334,16 @@ def new_tab(url="about:blank"): return cur.get("targetId") or cur.get("target_id") except Exception: pass - # BH_NO_ACTIVATE=1 → background:true so creating the tab doesn't raise the - # OS window. switch_tab's activateTarget gate alone can't stop this: - # Target.createTarget foregrounds the window on macOS before switch runs. + # background controls whether the new tab raises the OS window: None follows + # BH_NO_ACTIVATE, True/False overrides per call. switch_tab's activateTarget + # gate alone can't stop the raise — Target.createTarget foregrounds the + # window on macOS before switch runs, so gate creation here too. + bg = _no_activate() if background is None else background params = {"url": "about:blank"} - if os.environ.get("BH_NO_ACTIVATE") == "1": - params["background"] = True # Mac-only: open the tab without raising the window. + if bg: + params["background"] = True # opens the tab without raising the window (verified macOS) tid = cdp("Target.createTarget", **params)["targetId"] - switch_tab(tid) + switch_tab(tid, activate=not bg) if url != "about:blank": goto_url(url) return tid diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index c319fde1..c766bb90 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -313,17 +313,10 @@ async def send_raw(self, method, params=None, session_id=None): return d -def test_attach_first_page_creates_foreground_by_default(monkeypatch): - monkeypatch.delenv("BH_NO_ACTIVATE", raising=False) - d = _daemon_no_real_pages() - asyncio.run(d.attach_first_page()) - create = next(p for m, p, _ in d.cdp.calls if m == "Target.createTarget") - assert "background" not in create - - def test_attach_first_page_creates_background_when_no_activate(monkeypatch): monkeypatch.setenv("BH_NO_ACTIVATE", "1") d = _daemon_no_real_pages() asyncio.run(d.attach_first_page()) create = next(p for m, p, _ in d.cdp.calls if m == "Target.createTarget") assert create.get("background") is True + assert create["url"].startswith("data:") # keepalive: real anchor, not about:blank diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 62f2ac95..4a236d82 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -372,15 +372,41 @@ def fake_cdp(method, **kw): return calls -def test_new_tab_creates_foreground_by_default(monkeypatch): - calls = _record_tab_cdp(monkeypatch, no_activate=False) +def test_new_tab_creates_background_when_no_activate(monkeypatch): + calls = _record_tab_cdp(monkeypatch, no_activate=True) helpers.new_tab() create = next(kw for m, kw in calls if m == "Target.createTarget") - assert "background" not in create + assert create.get("background") is True -def test_new_tab_creates_background_when_no_activate(monkeypatch): - calls = _record_tab_cdp(monkeypatch, no_activate=True) - helpers.new_tab() +def test_new_tab_background_param_overrides_env(monkeypatch): + # explicit background=True backgrounds even without the env flag + calls = _record_tab_cdp(monkeypatch, no_activate=False) + helpers.new_tab(background=True) create = next(kw for m, kw in calls if m == "Target.createTarget") assert create.get("background") is True + + +def test_new_tab_background_param_skips_activate(monkeypatch): + # regression: backgrounding createTarget isn't enough — new_tab calls + # switch_tab, which must also skip Target.activateTarget or the window raises. + calls = _record_tab_cdp(monkeypatch, no_activate=False) + helpers.new_tab(background=True) + assert not any(m == "Target.activateTarget" for m, _ in calls) + + +def test_new_tab_explicit_background_bypasses_blank_reuse(monkeypatch): + # with an explicit background override, new_tab must create a fresh tab, not + # reuse the current blank one (which skips the activation logic entirely). + calls = [] + def fake_cdp(method, **kw): + calls.append((method, kw)) + if method == "Target.attachToTarget": return {"sessionId": "s"} + if method == "Target.createTarget": return {"targetId": "t"} + return {} + monkeypatch.setattr(helpers, "cdp", fake_cdp) + monkeypatch.setattr(helpers, "_send", lambda msg: {"targetId": "cur", "url": "about:blank", "title": ""}) + monkeypatch.setattr(helpers, "goto_url", lambda *a, **k: None) + monkeypatch.delenv("BH_NO_ACTIVATE", raising=False) + helpers.new_tab("https://example.com", background=True) + assert any(m == "Target.createTarget" for m, _ in calls)