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
6 changes: 6 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ PY
- Use the heredoc form for every multi-line command. It prevents shell quote mangling inside Python strings and JavaScript snippets.
- First navigation is new_tab(url), not goto_url(url) — goto runs in the user's active tab and clobbers their work.

## Local profiles

When multiple local Chrome profiles exist, do not choose one yourself. Run `browser-harness local-profiles` and `browser-harness default-profile`. If no default is configured, ask the user which listed profile to use before any page work. Only run `browser-harness default-profile --profile <id-or-name>` after the user explicitly names or confirms the profile.

Once a default is configured, startup opens a temporary marker tab in that profile, waits for the marker target over CDP, attaches to it to capture the Chrome `browserContextId`, then the first `new_tab()` opens the task tab in that same context and closes the marker tab. That task tab remains the controlled target across later commands. Future `new_tab()` calls stay pinned to that context, stale context ids are reacquired with a new marker, and target switches into a different profile context are refused.

## Tool call shape

```bash
Expand Down
13 changes: 13 additions & 0 deletions skills/browser-harness/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ PY
- First navigation is `new_tab(url)`, not `goto_url(url)` — goto runs in the user's active tab and clobbers their work.
- Helpers are pre-imported and the daemon auto-starts; you never start/stop it manually unless you want to.

## Target a Specific Local Profile

When multiple local Chrome profiles exist, do not choose one yourself. Run:

```bash
browser-harness local-profiles
browser-harness default-profile
```

If `default-profile` says no default is configured, show the profile list to the user and ask which profile to use before running any page work. Only run `browser-harness default-profile --profile <id-or-name>` after the user explicitly names or confirms the profile. If a default already exists, use it unless the user asks to switch.

After a default is configured, normal startup handles profile targeting in code: it opens a temporary marker tab in the selected profile, waits for that marker target over CDP, attaches to it to capture the Chrome `browserContextId`, then the first `new_tab()` opens the task tab in that same context and closes the marker tab. That task tab remains the controlled target across later commands. Future `new_tab()` calls stay pinned to that context, stale context ids are reacquired with a new marker, and switches to targets from another profile context are refused.

## What actually works

- **Screenshots first.** `capture_screenshot()` to understand the page, find visible targets, and decide whether you need a click, a selector, or more navigation.
Expand Down
101 changes: 83 additions & 18 deletions src/browser_harness/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ def _needs_chrome_remote_debugging_prompt(msg):
"""True when Chrome needs the inspect-page permission/profile flow."""
lower = (msg or "").lower()
return (
"devtoolsactiveport not found" in lower
# state tokens from browser.py's connection classifier
"permission-blocked" in lower
or "cdp-disabled" in lower
or "browser-closed" in lower
or "devtoolsactiveport not found" in lower
or "enable chrome://inspect" in lower
or "not live yet" in lower
or (
Expand All @@ -156,6 +160,32 @@ def _needs_chrome_remote_debugging_prompt(msg):
)


def _configured_profile_remote_debugging_enabled():
try:
from .browser import configured_profile_remote_debugging_enabled
return configured_profile_remote_debugging_enabled()
except Exception:
return None


def _focus_configured_profile():
try:
from .browser import open_configured_profile
return open_configured_profile()
except Exception:
return None


def _permission_blocked(msg):
lower = (msg or "").lower()
return (
"permission-blocked" in lower
or "403" in lower
or "forbidden" in lower
or "server rejected websocket connection" in lower
)


def _is_local_chrome_mode(env=None):
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
Expand Down Expand Up @@ -298,15 +328,23 @@ def run_doctor_fix_snap():
def ensure_daemon(wait=60.0, name=None, env=None):
"""Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
if daemon_alive(name):
# Stale daemons accept connects AND reply to meta:* (pure Python) even when the
# CDP WS to Chrome is dead — probe with a real CDP call and require "result".
# Warm startup must not re-probe Chrome with fresh CDP command

# Must go through ipc.connect so this works on Windows (TCP loopback) too;
# raw AF_UNIX here would fail on every warm call and churn the daemon.
s = None
try:
s, token = ipc.connect(name or NAME, timeout=3.0)
resp = ipc.request(s, token, {"method": "Target.getTargets", "params": {}})
if "result" in resp: return
capabilities = ipc.request(s, token, {"meta": "capabilities"}).get("capabilities", [])
if "create_target" in capabilities and "verify_profile" in capabilities:
# Re-anchor to the selected profile before the command runs. This
# is in-daemon over the existing websocket, so it won't re-prompt
ipc.request(s, token, {"meta": "verify_profile"})
return
except Exception: pass
finally:
if s:
s.close()
restart_daemon(name)

import subprocess, sys
Expand All @@ -324,8 +362,31 @@ def ensure_daemon(wait=60.0, name=None, env=None):
time.sleep(0.2)
msg = _log_tail(name) or ""
if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
_open_chrome_inspect()
print('browser-harness: at chrome://inspect/#remote-debugging, tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr)
if _permission_blocked(msg):
# 403 means endpoint exists but Chrome gating it behind
# "Allow remote debugging?" popup
# Focus the profile to surface
# the dialog and return a blocked state instead of looping setup.
_focus_configured_profile()
enabled = _configured_profile_remote_debugging_enabled()
detail = (
"remote debugging is already enabled"
if enabled is True
else "remote debugging must be enabled for the connection to be rejected this way"
)
raise RuntimeError(
"permission-blocked: Chrome rejected the CDP websocket with HTTP 403. "
f"The selected profile is focused and {detail}; "
"do not run setup or reload. Retry after the Chrome security dialog is accepted."
)
if "browser-closed" in msg.lower():
# No running Chrome to expose chrome://inspect, so open/focus the
# profile to give the user a window to enable debugging in.
_focus_configured_profile()
print('browser-harness: opened the selected Chrome profile — enable remote debugging at chrome://inspect/#remote-debugging, then retry', file=sys.stderr)
else:
_open_chrome_inspect()
print('browser-harness: at chrome://inspect/#remote-debugging, tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr)
restart_daemon(name)
continue
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
Expand Down Expand Up @@ -548,13 +609,14 @@ def start_remote_daemon(name="remote", profileName=None, **create_kwargs):


def list_local_profiles():
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
import json, shutil, subprocess
if not shutil.which("profile-use"):
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
"""Detected local Chromium-family profiles on this machine.

Native Python port of terminal's filesystem detector: reads known browser
install/profile roots and Chrome's Local State profile names. Does not
require profile-use.
"""
from .browser import list_local_profiles as _list_local_profiles
return _list_local_profiles()


def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
Expand Down Expand Up @@ -738,13 +800,16 @@ def _open_chrome_inspect():

def run_doctor():
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
import platform, shutil, sys
import platform, sys
cur = _version()
mode = _install_mode()
chrome = _chrome_running()
daemon = daemon_alive()
connections = browser_connections()
profile_use = shutil.which("profile-use") is not None
try:
local_profiles = list_local_profiles()
except Exception:
local_profiles = []
api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
latest = _latest_release_tag()
# Only claim an update when we know the installed version — `cur or "(unknown)"`
Expand Down Expand Up @@ -783,9 +848,9 @@ def row(label, ok, detail=""):
print(f" {conn['name']} — active page: {title} — {url}")
else:
print(f" {conn['name']} — active page: (no real page)")
row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
row("local profiles detected", bool(local_profiles), str(len(local_profiles)))
row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
# Core health = chrome + daemon. Profile-use/api-key are optional.
# Core health = chrome + daemon. Local profiles/api-key are optional.
return 0 if (chrome and daemon) else 1


Expand Down
Loading