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
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ If you get stuck on a browser mechanic, check https://github.com/browser-use/bro

- `chrome://inspect/#remote-debugging` must be enabled for local Chrome control.
- Chrome may show an "Allow remote debugging?" popup; wait for the user to click Allow.
- Tabs open in BACKGROUND by design. `new_tab`/`switch_tab` never raise the Chrome window; screenshot, click, and navigation all work on background tabs. Only `switch_tab(tid, activate=True)` brings a tab to the foreground - use it exclusively when the user asked to SEE the tab; it steals their OS focus.
- Rare visibility-sensitive page (pauses video/animation when hidden): escalate with `switch_tab(tid, activate=True)` for that step only.
- Omnibox popups are not real work tabs.
- CDP target order is not Chrome's visible tab-strip order.
- `BU_CDP_URL` is an HTTP DevTools endpoint; the daemon resolves it to WebSocket.
Expand Down
8 changes: 4 additions & 4 deletions interaction-skills/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

When Chrome opens fresh, the only CDP `type: "page"` targets are `chrome://inspect` and `chrome://omnibox-popup.top-chrome/` (a 1px invisible viewport). If the daemon attaches to the omnibox popup, all subsequent work — including `new_tab()` and `goto_url()` — happens on tabs that exist in CDP but may not be visible in the Chrome UI.

The daemon's `attach_first_page()` handles this by creating an `about:blank` tab when no real pages exist. If you still end up on an invisible tab, use `switch_tab()` which calls `Target.activateTarget` to bring the tab to front.
The daemon's `attach_first_page()` handles this by creating an `about:blank` tab (in background) when no real pages exist. If you still end up on an invisible tab, `switch_tab()` re-attaches to a real one; it does NOT bring the tab to front unless you pass `activate=True`.

## Startup sequence

1. Check if a daemon is already running with `daemon_alive()`
2. If stale sockets exist but daemon is dead, clean them up
3. List open tabs with `list_tabs()` to see what's available
4. `ensure_real_tab()` attaches to a real page
5. `switch_tab(target_id)` both attaches AND activates (brings to front)
5. `switch_tab(target_id)` attaches in background; `switch_tab(target_id, activate=True)` also brings it to front (steals the user's OS focus — only when they asked to see it)

```python
if not daemon_alive():
Expand All @@ -29,9 +29,9 @@ for t in tabs:
tab = ensure_real_tab()
```

## Bringing Chrome to front
## Bringing Chrome to front (opt-in ONLY)

If Chrome is behind other windows or on another desktop:
Never do this as part of normal automation — it steals the user's focus and interrupts whatever they are doing. Only when the user explicitly asks to watch:

```python
import subprocess
Expand Down
12 changes: 7 additions & 5 deletions interaction-skills/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ Use **CDP for control**, **UI automation for user-visible order**.
```python
tabs = list_tabs() # includes chrome:// pages too
real_tabs = list_tabs(include_chrome=False)
tid = new_tab("https://example.com") # create + attach
switch_tab(tid) # attach harness to tab
cdp("Target.activateTarget", targetId=tid) # show it in Chrome
tid = new_tab("https://example.com") # create + attach (in background — never raises the window)
switch_tab(tid) # attach harness to tab (background by default)
switch_tab(tid, activate=True) # attach AND visibly show it in Chrome (steals OS focus!)
print(current_tab())
print(page_info())
```

**Focus discipline:** automation almost never needs `activate=True` — screenshots, clicks, and navigation all work on background tabs. The one exception is visibility-sensitive pages that pause video/animation when hidden (see SKILL.md). Only pass `activate=True` (or call `Target.activateTarget`) when the user explicitly asked to SEE the tab; on macOS it yanks the Chrome window to the foreground and steals the user's focus.

What CDP is good at:
- attach to a tab
- open a tab
Expand Down Expand Up @@ -61,8 +63,8 @@ Typical tools:

## Rules that held up in practice

- `switch_tab()` is **not enough** if the user expects Chrome to visibly change.
- `Target.activateTarget` is the CDP-side "show this tab".
- `switch_tab()` is **not enough** if the user expects Chrome to visibly change — use `switch_tab(tid, activate=True)` for that, and only then.
- `Target.activateTarget` is the CDP-side "show this tab". It steals OS focus on macOS; opt-in only.
- `list_tabs()` includes `chrome://newtab/` by default; ask for `include_chrome=False` when you want only real pages.
- `chrome://omnibox-popup.top-chrome/` can appear as a fake page target; ignore it for user-facing tab lists.
- If a page has `w=0 h=0`, you may be attached to the wrong target or a non-window surface.
Expand Down
30 changes: 14 additions & 16 deletions src/browser_harness/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,23 +759,21 @@ def _chrome_running():


def _open_chrome_inspect():
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
import platform, subprocess, webbrowser
"""Point the user at chrome://inspect/#remote-debugging without stealing focus.

Used to `osascript ... activate` Chrome and open the URL itself, but that
yanks the user's real Chrome to the foreground on a transient attach
failure — exactly the focus-stealing this harness must never do. The
dedicated-profile setup (chrome-debug, port 9333) doesn't need the
consent checkbox at all, so just print instructions and let the user act."""
import sys
url = "chrome://inspect/#remote-debugging"
if platform.system() == "Darwin":
try:
subprocess.run([
"osascript",
"-e", 'tell application "Google Chrome" to activate',
"-e", f'tell application "Google Chrome" to open location "{url}"',
], timeout=5, check=False)
return
except Exception:
pass
try:
webbrowser.open(url, new=2)
except Exception:
pass
print(
f"browser-harness: open {url} in the target Chrome yourself, "
"or (preferred) run `chrome-debug` to use the dedicated automation profile "
"that never needs the consent checkbox.",
file=sys.stderr,
)


def run_doctor():
Expand Down
49 changes: 47 additions & 2 deletions src/browser_harness/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ 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"]
# background=True keeps it from raising the Chrome window.
tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank", "background": True}))["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(
Expand Down Expand Up @@ -257,7 +258,51 @@ async def enable_one(d):
)
except Exception as e:
log(f"enable {d} on {session_id}: {e}")
await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")))

async def set_focus_emulation():
await asyncio.wait_for(
self.cdp.send_raw(
"Emulation.setFocusEmulationEnabled",
{"enabled": True},
session_id=session_id,
),
timeout=4,
)

async def enable_focus_emulation():
# Tabs stay in background by design (never activated, so the user's
# focus is never stolen). A hidden RenderWidget doesn't process
# Input.dispatchMouseEvent — the event never acks and clicks are
# dropped. Focus emulation makes the renderer treat the page as
# focused/active so compositor-level input works on background tabs.
try:
await set_focus_emulation()
return
except Exception as e:
log(f"enable focus emulation on {session_id} (attempt 1): {e}")

# Retry OFF the synchronous set_session path: the helper's IPC
# socket read times out at 5s, so this gather must stay bounded by
# one 4s round trip. A second failure gets a loud log — without
# focus emulation, clicks/typing on this tab silently do nothing.
async def retry_in_background():
try:
await set_focus_emulation()
log(f"focus emulation enabled on {session_id} (background retry)")
except Exception as e:
log(
f"WARNING: focus emulation NOT active on {session_id} "
f"({e}) — clicks/typing will be dropped while this tab "
"is in background; switch_tab(tid, activate=True) is "
"the workaround"
)

asyncio.create_task(retry_in_background())
Comment thread
matheus-conin marked this conversation as resolved.

await asyncio.gather(
Comment thread
matheus-conin marked this conversation as resolved.
*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")),
enable_focus_emulation(),
)

async def start(self):
self.stop = asyncio.Event()
Expand Down
69 changes: 55 additions & 14 deletions src/browser_harness/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,25 @@ def click_at_xy(x, y, button="left", clicks=1):
except Exception as e:
print(f"[debug_click] overlay failed: {e}")
_debug_click_counter += 1
# mouseMoved primes the pointer position; on background tabs (never
# activated, focus-emulated by the daemon) press/release without a prior
# move is processed but the click doesn't fire.
cdp("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y)
cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button=button, clickCount=clicks)
cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button=button, clickCount=clicks)
if button == "left" and clicks == 1:
# On background tabs the compositor click runs handlers/navigation but
# does NOT move DOM focus (activeElement stays BODY), so click-then-
# type_text breaks. Mirror what a real click does: focus the focusable
# element under the point. No-op on active tabs (already focused).
try:
js(
f"(()=>{{const e=document.elementFromPoint({x},{y});"
f"if(!e)return;const t=e.closest('input,textarea,select,button,[contenteditable],[tabindex]');"
f"if(t&&document.activeElement!==t)t.focus();}})()"
)
except Exception:
pass

def type_text(text):
cdp("Input.insertText", text=text)
Expand All @@ -194,16 +211,30 @@ def fill_input(selector, text, clear_first=True, timeout=0.0):
if not focused:
raise RuntimeError(f"fill_input: element not found: {selector!r}")
if clear_first:
# Dispatch select-all directly — NOT via press_key, which always emits a
# `char` event for single-char keys. With Ctrl/Cmd held, that `char`
# makes Chrome treat the input as a printable "a" instead of firing the
# select-all shortcut, leaving the field uncleared.
mods = 4 if sys.platform == "darwin" else 2 # Cmd on macOS, Ctrl elsewhere
select_all = {"key": "a", "code": "KeyA", "modifiers": mods,
"windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65}
cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all)
cdp("Input.dispatchKeyEvent", type="keyUp", **select_all)
press_key("Backspace")
# Select-all via element.select(), not a Cmd/Ctrl+A key dance: the key
# sequence never sent Meta's own keyDown/keyUp, so on focus-emulated
# background tabs the modifier stayed latched and every subsequent
# char became a Cmd+<char> shortcut (field silently stayed empty).
# Only select when there IS content: select() on an empty field leaves
# it in a state where subsequent char events don't insert at all.
had_content = js(
Comment thread
matheus-conin marked this conversation as resolved.
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
f"if(!e)return false;"
f"const v=('value'in e)?e.value:e.textContent;"
f"if(!v)return false;"
f"if(e.select)e.select();else document.getSelection().selectAllChildren(e);"
f"return true;}})()"
)
if had_content:
press_key("Backspace")
else:
# Append semantics: JS focus() parks the caret at position 0, so typed
# text would land BEFORE the existing value. Move the caret to the end.
js(
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
f"if(e&&e.setSelectionRange&&'value'in e)"
f"try{{e.setSelectionRange(e.value.length,e.value.length)}}catch(_){{}}}})()"
)
for ch in text:
press_key(ch)
js(
Expand All @@ -229,9 +260,14 @@ def press_key(key, modifiers=0):
base = {"key": key, "code": code, "modifiers": modifiers, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk}
shortcut_modifiers = modifiers & (1 | 2 | 4) # Alt/Ctrl/Meta turn single keys into shortcuts.
printable_char = len(key) == 1 and bool(text) and not shortcut_modifiers
# keyDown/keyUp feed key listeners and editing actions (Backspace, Enter,
# arrows); the printable character itself is delivered via Input.insertText.
# On focus-emulated background tabs, text carried on keyDown or char events
# inserts unreliably (empty fields drop it, and keyDown+text plus a char
# event double-inserts) - insertText is deterministic.
cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({} if printable_char or not text else {"text": text}))
if printable_char:
cdp("Input.dispatchKeyEvent", type="char", text=text, **{k: v for k, v in base.items() if k != "text"})
cdp("Input.insertText", text=text)
cdp("Input.dispatchKeyEvent", type="keyUp", **base)

def scroll(x, y, dy=-300, dx=0):
Expand Down Expand Up @@ -291,15 +327,19 @@ 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 switch_tab(target, activate=False):
# 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)
# activate=True raises the Chrome window to the macOS foreground (steals the
# user's focus). Automation never needs it — attach/screenshot/click all work
# on background tabs — so only pass it when the user asked to SEE the tab.
if 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()
Expand All @@ -318,7 +358,8 @@ 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"]
# background=True keeps the new tab from raising the Chrome window.
tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
switch_tab(tid)
if url != "about:blank":
goto_url(url)
Expand Down
14 changes: 7 additions & 7 deletions tests/unit/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ async def run():
# in-flight. Cap iterations to avoid hanging if parallelization breaks.
for _ in range(50):
await asyncio.sleep(0)
# 5 = Network.disable on OLD + 4 enables on NEW.
# 6 = Network.disable on OLD + 4 enables + focus emulation on NEW.
if d.cdp.in_flight >= 5:
break
peak = d.cdp.max_concurrent
Expand All @@ -187,10 +187,10 @@ async def run():
return peak, d.cdp.calls

peak, calls = asyncio.run(run())
assert peak == 5, (
f"set_session must run disable + 4 enables concurrently via gather "
f"(observed peak in-flight = {peak}; expected 5 = 1 disable on OLD + "
f"4 enables on NEW). Sequential await would peak at 1."
assert peak == 6, (
f"set_session must run disable + 4 enables + focus emulation "
f"concurrently via gather (observed peak in-flight = {peak}; expected "
f"6 = 1 disable on OLD + 5 on NEW). Sequential await would peak at 1."
)
# Sanity: the right calls were made.
methods = sorted({m for (m, _p, _s) in calls})
Expand Down Expand Up @@ -239,8 +239,8 @@ async def run():
return peak

peak = asyncio.run(run())
assert peak == 4, (
f"first set_session must run 4 enables concurrently "
assert peak == 5, (
f"first set_session must run 4 enables + focus emulation concurrently "
f"(observed peak = {peak}). No Network.disable should fire."
)

Expand Down
Loading