Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", ...)`.
Expand Down
2 changes: 2 additions & 0 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
15 changes: 12 additions & 3 deletions src/browser_harness/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,<title>browser-harness</title>" 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"]
Expand Down
36 changes: 30 additions & 6 deletions src/browser_harness/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,25 +291,41 @@ 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
# Unmark old tab. Horse emoji is a surrogate pair in JS UTF-16 strings (2 code units),
# 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 ""
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 60 additions & 0 deletions tests/unit/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)