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 a4d7e00e..242a30e1 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -201,9 +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. - tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"] - log(f"no real pages found, created about:blank ({tid})") - pages = [{"targetId": tid, "url": "about:blank", "type": "page"}] + 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 {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 d1dfd37a..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 @@ -299,17 +304,28 @@ 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. + # 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 "" @@ -318,8 +334,16 @@ 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"] - switch_tab(tid) + # 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 bg: + params["background"] = True # opens the tab without raising the window (verified macOS) + tid = cdp("Target.createTarget", **params)["targetId"] + 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 90c5bc85..c766bb90 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -293,3 +293,30 @@ 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_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 4a45ee07..4a236d82 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -350,3 +350,63 @@ 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_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 + + +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)