Files
workbuddy_skills/browser-harness/src/browser_harness/admin.py
T
admin 777f7fe5d0 重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。
browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
2026-10-07 16:06:02 +08:00

1063 lines
43 KiB
Python

import json
import os
import re
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from . import _ipc as ipc
from . import auth
from . import paths
def _process_start_time(pid):
"""Opaque process-start-time fingerprint at PID, or None if unavailable.
Two reads returning the same non-None value mean the PID still refers to
the same process; a different value means the PID was reused. Used by
restart_daemon() to keep the force-kill recovery path working even when
the daemon has already torn down its IPC socket (e.g. during a slow
remote shutdown), without falling back to "trust the pid file" — which
would re-introduce the PID-reuse hazard.
Linux: /proc/<pid>/stat field 22 (starttime in clock ticks since boot).
macOS: `ps -o lstart= -p <pid>` (an absolute timestamp string).
Windows: GetProcessTimes via ctypes (FILETIME creation time, 100-ns since 1601).
Anywhere else: returns None; restart_daemon falls back to its strict
identify-only check, which is safer than no check at all.
"""
if type(pid) is not int or pid <= 0:
return None
if sys.platform.startswith("linux"):
try:
with open(f"/proc/{pid}/stat", "rb") as f:
raw = f.read().decode("ascii", errors="replace")
except (FileNotFoundError, PermissionError, OSError):
return None
# Field 2 is `(comm)`; comm can contain spaces and parens, so split off
# everything after the LAST `)` and index from there.
try:
tail = raw[raw.rindex(")") + 2:].split()
return tail[19] # starttime is field 22 (0-indexed: 21 - skipped 2 = 19)
except (ValueError, IndexError):
return None
if sys.platform == "darwin":
try:
out = subprocess.check_output(
["ps", "-o", "lstart=", "-p", str(pid)],
stderr=subprocess.DEVNULL, timeout=2,
)
except (subprocess.SubprocessError, OSError):
return None
s = out.decode("ascii", errors="replace").strip()
return s or None
if sys.platform == "win32":
# Windows users running a remote daemon hit the same slow-shutdown
# window as POSIX (stop_remote() PATCHes api.browser-use.com after
# the IPC socket has been torn down). Without a fingerprint here the
# SIGTERM gate can never pass during that window, leaving an orphan
# daemon that may continue to hold a billed cloud browser. Use
# GetProcessTimes via ctypes to read the kernel-reported creation
# time as a 64-bit FILETIME (100-ns intervals since 1601-01-01).
try:
import ctypes
from ctypes import wintypes
except ImportError:
return None
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetProcessTimes.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
]
kernel32.GetProcessTimes.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
except (OSError, AttributeError):
return None
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return None
try:
creation = wintypes.FILETIME()
exit_ft = wintypes.FILETIME()
kernel_ft = wintypes.FILETIME()
user_ft = wintypes.FILETIME()
ok = kernel32.GetProcessTimes(
h, ctypes.byref(creation), ctypes.byref(exit_ft),
ctypes.byref(kernel_ft), ctypes.byref(user_ft),
)
if not ok:
return None
return (creation.dwHighDateTime << 32) | creation.dwLowDateTime
finally:
kernel32.CloseHandle(h)
return None
def _load_env():
repo_root = Path(__file__).resolve().parents[2]
workspace = paths.workspace_dir()
for p in (repo_root / ".env", workspace / ".env"):
if not p.exists():
continue
_load_env_file(p)
def _load_env_file(p):
for line in p.read_text(encoding="utf-8-sig", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
_load_env()
NAME = os.environ.get("BU_NAME", "default")
BU_API = "https://api.browser-use.com/api/v3"
PYPI_JSON = "https://pypi.org/pypi/browser-harness/json"
VERSION_CACHE = paths.config_dir() / "version-cache.json"
VERSION_CACHE_TTL = 24 * 3600
DOCTOR_TEXT_LIMIT = 140
def _log_tail(name):
try:
return ipc.log_path(name or NAME).read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1]
except (FileNotFoundError, IndexError, OSError):
return None
def _needs_chrome_remote_debugging_prompt(msg):
"""True when Chrome needs the inspect-page permission flow."""
lower = (msg or "").lower()
return (
"devtoolsactiveport not found" in lower
or "enable chrome://inspect" in lower
or "not live yet" in lower
or (
"ws handshake failed" in lower
and (
"403" in lower
or "opening handshake" in lower
or "timed out" in lower
or "timeout" in lower
)
)
)
def _needs_chrome_permission_popup(msg):
"""True when Chrome is reachable but waiting on the per-session Allow popup."""
lower = (msg or "").lower()
return "permission-blocked" in lower
def _chrome_not_running(msg):
"""True when the daemon found no running supported browser"""
return "chrome-not-running" in (msg or "").lower()
def _is_local_chrome_mode(env=None):
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
env = env or {}
return not (
env.get("BU_CDP_WS")
or env.get("BU_CDP_URL")
or os.environ.get("BU_CDP_WS")
or os.environ.get("BU_CDP_URL")
)
def daemon_alive(name=None):
# Ping handshake (not a bare connect) so a stale .port file + port reuse
# after a daemon crash doesn't make us mistake an unrelated listener for ours.
return ipc.ping(name or NAME, timeout=1.0)
def daemon_browser_kind(name=None):
"""'cloud' | 'cdp' | 'local' as self-reported by a live daemon, else None.
None covers unreachable daemons and pre-browser_kind daemons still running
from an older version."""
c = None
try:
c, token = ipc.connect(name or NAME, timeout=1.0)
response = ipc.request(c, token, {"meta": "ping"})
kind = response.get("browser_kind") if isinstance(response, dict) else None
return kind if kind in {"cloud", "cdp", "local"} else None
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, ValueError):
return None
finally:
if c:
c.close()
def _daemon_endpoint_names():
# BH_RUNTIME_DIR isolates one daemon per dir → no filename-prefix discovery,
# just check whether our local endpoint exists. Without BH_RUNTIME_DIR, or
# with BH_RUNTIME_DIR_SHARED=1, _RUNTIME is shared and we glob `bu-*.<suffix>`
# to find every daemon in that runtime dir.
suffix = ".port" if ipc.IS_WINDOWS else ".sock"
if ipc.BH_RUNTIME_DIR and not ipc.BH_RUNTIME_DIR_SHARED:
return [NAME] if (ipc._RUNTIME / f"bu{suffix}").exists() else []
names = []
for p in sorted(ipc._RUNTIME.glob(f"bu-*{suffix}")):
raw = p.name[3:-len(suffix)]
try:
ipc._check(raw)
except ValueError:
continue
names.append(raw)
return names
def _daemon_browser_connection(name):
c = None
try:
c, token = ipc.connect(name, timeout=1.0)
response = ipc.request(c, token, {"meta": "connection_status"})
if "error" in response:
return None
page = response.get("page")
if page:
page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
return {"name": name, "page": page}
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
return None
finally:
if c:
c.close()
def browser_connections():
"""Live browser-harness daemons with healthy CDP browser connections and their attached page."""
out = []
for name in _daemon_endpoint_names():
conn = _daemon_browser_connection(name)
if conn:
out.append(conn)
return out
def active_browser_connections():
"""Count live browser-harness daemons with a healthy CDP browser connection."""
return len(browser_connections())
def _doctor_short_text(value, limit=None):
limit = limit or DOCTOR_TEXT_LIMIT
value = str(value)
return value if len(value) <= limit else value[:limit - 3] + "..."
def _is_snap_browser(path: str) -> bool:
"""True when a Chrome binary path lives under /snap/ (Snap confinement on Linux)."""
return bool(path) and "/snap/" in path.lower()
def _doctor_snap_probe_path(path: str) -> str:
raw = str(path)
try:
resolved = os.path.realpath(raw)
except OSError:
resolved = raw
return raw if _is_snap_browser(raw) else resolved
def _doctor_probe_chrome_binary_for_snap():
"""Return (label, probe_path) for the first Chrome/Chromium binary found, else (None, None).
Honors BH_CHROME_PATH and CHROME_PATH before searching PATH for common names.
"""
import shutil
for key in ("BH_CHROME_PATH", "CHROME_PATH"):
raw = (os.environ.get(key) or "").strip()
if not raw:
continue
p = Path(raw).expanduser()
try:
if p.is_file():
return (p.name, _doctor_snap_probe_path(str(p)))
except OSError:
continue
for cmd in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium"):
w = shutil.which(cmd)
if not w:
continue
try:
return (cmd, _doctor_snap_probe_path(w))
except OSError:
continue
return (None, None)
def _snap_linux_headless_doc_url():
return "https://github.com/browser-use/browser-harness/blob/main/docs/snap-linux-headless.md"
def run_doctor_fix_snap():
"""Print steps to replace Snap Chromium with a native Chrome for CDP. Always exit 0."""
doc = _snap_linux_headless_doc_url()
print("browser-harness doctor --fix-snap")
print()
print("Snap-packaged Chromium cannot expose DevTools the way browser-harness needs.")
print(f"Full background: {doc}")
print()
print("1. Install Google Chrome from Google's .deb (not the Snap store):")
print(" wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb")
print(" sudo apt install ./google-chrome-stable_current_amd64.deb")
print()
print("2. Point the harness (and your shell) at the native binary so PATH does not")
print(" pick the Snap wrapper first. Example for bash (~/.bashrc or session env):")
print(" export BH_CHROME_PATH=/usr/bin/google-chrome-stable")
print(" # CHROME_PATH is also honored by doctor's snap probe if you prefer that name.")
print()
print("3. Launch Chrome from that path (Way 2) or open Chrome normally (Way 1),")
print(" enable remote debugging per install.md, then verify:")
print(" browser-harness --doctor")
print()
return 0
def ensure_daemon(wait=60.0, name=None, env=None):
"""Idempotent. Self-heals stale daemon, closed Chrome (launches it), 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".
# 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.
for last in (False, True):
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
except Exception:
pass
if not last: time.sleep(0.5)
restart_daemon(name)
import subprocess, sys
local = _is_local_chrome_mode(env)
launched_browser = False
opened_inspect = False
for _ in range(3):
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
try:
stderr_sink = open(ipc.log_path(name or NAME), "ab")
except OSError:
stderr_sink = subprocess.DEVNULL
p = subprocess.Popen(
[sys.executable, "-m", "browser_harness.daemon"],
env=e, stdout=subprocess.DEVNULL, stderr=stderr_sink, **ipc.spawn_kwargs(),
)
if stderr_sink is not subprocess.DEVNULL:
stderr_sink.close()
spawned = time.time()
deadline = spawned + wait
hinted = not local
while time.time() < deadline:
if daemon_alive(name): return
if p.poll() is not None: break
if not hinted and time.time() - spawned > 2 and (_log_tail(name) or "").startswith("handshake-wait"):
print('browser-harness: Chrome is asking "Allow remote debugging?" — click Allow to continue.', file=sys.stderr)
hinted = True
time.sleep(0.2)
msg = _log_tail(name) or ""
if local and msg.startswith("handshake-wait"):
restart_daemon(name)
raise RuntimeError(
"permission-blocked: Chrome's Allow popup was not clicked in time -- wait for the user to click Allow, then retry."
)
if local and _needs_chrome_permission_popup(msg):
print('browser-harness: Chrome is asking "Allow remote debugging?". Click Allow in Chrome, then retry browser work.', file=sys.stderr)
restart_daemon(name)
raise RuntimeError(
"permission-blocked: wait for the user to click Allow in the Chrome permission popup before retrying."
)
if local and not launched_browser and _chrome_not_running(msg):
# Chrome is closed — launch the browser and retry
launched_browser = True
restart_daemon(name)
if not _launch_browser():
raise RuntimeError(
"chrome-not-running: no supported browser is running and none could be launched -- ask the user to open Chrome, then retry."
)
print("browser-harness: Chrome isn't running — launching it. If Chrome shows an \"Allow remote debugging?\" popup, click Allow.", file=sys.stderr)
from .daemon import supported_browser_running
boot_deadline = time.time() + 15
while time.time() < boot_deadline and not supported_browser_running():
time.sleep(0.3)
continue
if local and not opened_inspect and _needs_chrome_remote_debugging_prompt(msg):
opened_inspect = True
from .daemon import remote_debugging_toggle_profiles, remote_debugging_user_enabled
if remote_debugging_user_enabled():
# chrome://inspect toggle is already on — connection died
print('browser-harness: Chrome is asking "Allow remote debugging?". Click Allow in Chrome, then retry browser work.', file=sys.stderr)
restart_daemon(name)
raise RuntimeError(
"permission-blocked: wait for the user to click Allow in the Chrome permission popup before retrying."
)
restart_daemon(name)
_open_chrome_inspect_once()
if remote_debugging_toggle_profiles():
# Toggle already ticked from a previous run, but Chrome 144+
# wants new Allow for this browser run.
todo = 'click Allow on Chrome\'s "Allow remote debugging?" popup (the checkbox is already ticked; if no popup appears, untick and re-tick it)'
else:
todo = 'tick "Allow remote debugging for this browser instance" and click Allow on the popup'
raise RuntimeError(
f"remote-debugging-setup: opened chrome://inspect/#remote-debugging in Chrome -- ask the user to {todo}. "
"Warn them Chrome shows ONE more Allow popup when the harness connects on the next attempt (per-connection approval; it is expected, not a re-ask). "
"Retry after the user confirms; do not retry before."
)
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
def stop_remote_daemon(name="remote"):
"""Stop a remote daemon and its backing Browser Use cloud browser.
Triggers the daemon's clean shutdown, which PATCHes
/browsers/{id} {"action":"stop"} so billing ends and any profile
state in the session is persisted."""
# restart_daemon is misnamed — it only stops the daemon (sends
# shutdown, SIGTERMs if needed, unlinks socket+pid). It never
# restarts anything on its own; a follow-up `browser-harness`
# call would auto-spawn a fresh one via ensure_daemon(). That
# "run-it-again-to-restart" workflow is why it was named that way.
restart_daemon(name)
def restart_daemon(name=None):
"""Best-effort daemon shutdown + socket/pid cleanup.
Name is historical: callers typically follow this with another
`browser-harness` invocation, which auto-spawns a fresh daemon via
ensure_daemon(). The function itself only stops.
Identity is verified via ipc.identify() before any process signal, so
a stale pid file whose number has been reused by an unrelated process
is never SIGTERM'd. If the daemon is unreachable, we just clean up the
pid file and socket and return — never escalate to a kill-by-pid-file.
"""
import signal
name = name or NAME
pid_path = str(ipc.pid_path(name))
# Two pieces of information are tracked separately:
# - daemon_pid: the daemon's self-reported PID, or None. Only daemons
# running this version (or newer) include `pid` in the ping response;
# pre-upgrade daemons return {pong: True} only and yield None here.
# - daemon_alive: whether ANY daemon answers ping. Keeps the shutdown
# IPC path working across upgrades — without it, a still-running
# pre-upgrade daemon would have its socket deleted out from under it
# while the process stayed alive.
daemon_pid = ipc.identify(name, timeout=5.0)
daemon_alive = daemon_pid is not None or ipc.ping(name, timeout=1.0)
# Snapshot the daemon's process start-time as a secondary identity check.
# The IPC socket can disappear before the process exits (e.g. the shutdown
# path tears down the socket and then waits on a slow remote `stop` PATCH),
# so identify() going None partway through is not proof of process death.
# Comparing start-time before SIGTERM lets us recover the original
# force-kill behavior for slow shutdowns without re-opening the
# PID-reuse hole — a reused PID would have a different start-time.
daemon_start = _process_start_time(daemon_pid)
if daemon_alive:
try:
c, token = ipc.connect(name, timeout=5.0)
ipc.request(c, token, {"meta": "shutdown"})
c.close()
except Exception:
pass
if daemon_pid is not None:
for _ in range(75):
try:
os.kill(daemon_pid, 0)
time.sleep(0.2)
except (ProcessLookupError, OSError, SystemError, OverflowError):
break
else:
# Re-verify identity before escalating to SIGTERM. Two acceptable
# signals, in priority order:
# 1. ipc.identify() still returns the same PID — daemon's IPC is
# live, daemon is wedged. Safe to kill.
# 2. start-time fingerprint of the original PID is unchanged —
# same process, just slow to exit (e.g. stuck in remote stop).
# The IPC may already be gone; that's expected.
# If neither holds, the PID may have been reused; skip SIGTERM.
verified_pid = ipc.identify(name, timeout=1.0)
same_process = verified_pid == daemon_pid or (
daemon_start is not None
and _process_start_time(daemon_pid) == daemon_start
)
if same_process:
try:
os.kill(daemon_pid, signal.SIGTERM)
except (ProcessLookupError, OSError, SystemError, OverflowError):
pass
ipc.cleanup_endpoint(name)
try:
os.unlink(pid_path)
except FileNotFoundError:
pass
def _browser_use(path, method, body=None):
key = auth.get_browser_use_api_key()
req = urllib.request.Request(
f"{BU_API}{path}",
method=method,
data=(json.dumps(body).encode() if body is not None else None),
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
)
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
def _stop_cloud_browser(browser_id):
if not browser_id:
return
try:
_browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
except BaseException:
pass
def _cdp_ws_from_url(cdp_url):
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
def _has_local_gui():
"""True when this machine plausibly has a browser we can open. False on headless servers."""
import platform
system = platform.system()
if system in ("Darwin", "Windows"):
return True
if system == "Linux":
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
return False
def _show_live_url(url):
"""Print liveUrl and auto-open it locally if there's a GUI."""
import sys, webbrowser
if not url: return
print(url)
if not _has_local_gui():
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
return
try:
webbrowser.open(url, new=2)
print("(opened liveUrl in your default browser)", file=sys.stderr)
except Exception as e:
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
def list_cloud_profiles():
"""List cloud profiles under the current API key.
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
is the array of domain strings the cloud profile has cookies for — use
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
Paginates through all pages — the API caps `pageSize` at 100."""
out, page = [], 1
while True:
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
items = listing.get("items") if isinstance(listing, dict) else listing
if not items:
break
for p in items:
detail = _browser_use(f"/profiles/{p['id']}", "GET")
out.append({
"id": detail["id"],
"name": detail.get("name"),
"userId": detail.get("userId"),
"cookieDomains": detail.get("cookieDomains") or [],
"lastUsedAt": detail.get("lastUsedAt"),
})
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
break
page += 1
return out
def _resolve_profile_name(profile_name):
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
if not matches:
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
if len(matches) > 1:
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
return matches[0]["id"]
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
"""Provision a Browser Use cloud browser and start a daemon attached to it.
kwargs forwarded to `POST /browsers` (camelCase):
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
timeout — minutes, 1..240.
customProxy — {host, port, username, password, ignoreCertErrors}.
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
Returns the full browser dict including `liveUrl`. Prints the liveUrl and
auto-opens it locally when a GUI is detected, so the user can watch along."""
if daemon_alive(name):
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
if profileName:
if "profileId" in create_kwargs:
raise RuntimeError("pass profileName OR profileId, not both")
create_kwargs["profileId"] = _resolve_profile_name(profileName)
browser = _browser_use("/browsers", "POST", create_kwargs)
try:
ensure_daemon(
name=name,
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
)
except BaseException:
_stop_cloud_browser(browser.get("id"))
raise
_show_live_url(browser.get("liveUrl"))
return browser
def list_local_profiles():
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`."""
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, encoding="utf-8", errors="replace"))
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
include_domains=None, exclude_domains=None):
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
Shells out to `profile-use sync` (v1.0.5+). Requires BROWSER_USE_API_KEY.
profile-use copies the profile dir to a temp and syncs from the copy, so Chrome
can stay open.
Args:
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
browser: disambiguate when multiple browsers have profiles of the
same name (e.g. "Google Chrome"). Default: any match.
cloud_profile_id: push cookies into this existing cloud profile instead of
creating a new one. Idempotent — call again to refresh
the same profile. Default: create new.
include_domains: only sync cookies for these domains (and subdomains).
Leading dot is optional. Example: ["google.com", "stripe.com"].
exclude_domains: drop cookies for these domains (and subdomains). Applied
before `include_domains` so exclude wins on overlap."""
import shutil, subprocess, sys
if not shutil.which("profile-use"):
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
key = auth.get_browser_use_api_key()
cmd = ["profile-use", "sync", "--profile", profile_name]
if browser:
cmd += ["--browser", browser]
if cloud_profile_id:
cmd += ["--cloud-profile-id", cloud_profile_id]
for d in include_domains or []:
cmd += ["--domain", d]
for d in exclude_domains or []:
cmd += ["--exclude-domain", d]
r = subprocess.run(cmd, text=True, encoding="utf-8", errors="replace", capture_output=True, env={**os.environ, "BROWSER_USE_API_KEY": key})
sys.stdout.write(r.stdout)
sys.stderr.write(r.stderr)
if r.returncode != 0:
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
# instead of "Profile created: <uuid>", so we already know the UUID.
if cloud_profile_id:
return cloud_profile_id
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
if not m:
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
return m.group(1)
def _version():
"""Installed version of the browser-harness package. Empty string if unknown."""
try:
from importlib.metadata import PackageNotFoundError, version
try:
return version("browser-harness")
except PackageNotFoundError:
return ""
except Exception:
return ""
def _repo_dir():
"""Return the repo root if this install is an editable git clone, else None."""
for p in Path(__file__).resolve().parents:
if (p / ".git").is_dir():
return p
return None
def _install_mode():
""""git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
if _repo_dir():
return "git"
return "pypi" if _version() else "unknown"
def _cache_read():
try:
return json.loads(VERSION_CACHE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _cache_write(data):
try:
VERSION_CACHE.parent.mkdir(parents=True, exist_ok=True)
VERSION_CACHE.write_text(json.dumps(data))
try:
os.chmod(VERSION_CACHE, 0o600)
except OSError:
pass
except OSError:
pass
def _latest_release_tag(force=False):
"""Return latest PyPI version, or None. Cached for 24h to avoid hammering PyPI."""
cache = _cache_read()
now = time.time()
if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
return cache["tag"]
try:
tag = json.loads(urllib.request.urlopen(PYPI_JSON, timeout=5).read()).get("info", {}).get("version") or ""
except Exception:
return cache.get("tag") # fall back to last known
tag = tag.lstrip("v")
_cache_write({**cache, "tag": tag, "fetched_at": now})
return tag or None
def _version_tuple(v):
"""Best-effort PEP 440-ish parse: alpha < beta < rc < final."""
m = re.match(r"^\s*v?(\d+(?:\.\d+)*)(?:(a|b|rc)(\d+))?", v or "", re.I)
if not m:
return (0, 0, 0, 3, 0)
nums = [int(p) for p in m.group(1).split(".")[:3]]
nums.extend([0] * (3 - len(nums)))
pre = (m.group(2) or "").lower()
pre_rank = {"a": 0, "b": 1, "rc": 2}.get(pre, 3)
pre_num = int(m.group(3) or 0)
return (*nums, pre_rank, pre_num)
def check_for_update():
"""(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
cur = _version()
latest = _latest_release_tag()
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
return cur, latest, newer
def print_update_banner(out=None):
"""Print the update banner to stderr once per day. Silent when up-to-date or offline."""
import sys
out = out or sys.stderr
cache = _cache_read()
today = time.strftime("%Y-%m-%d")
if cache.get("banner_shown_on") == today:
return
cur, latest, newer = check_for_update()
if not newer:
return
print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
_cache_write({**cache, "banner_shown_on": today})
def _chrome_running():
"""Cross-platform best-effort check for a running Chromium-based browser."""
import platform, subprocess
system = platform.system()
try:
if system == "Windows":
out = subprocess.check_output(["tasklist"], text=True, errors="replace", timeout=5)
names = ("chrome.exe", "msedge.exe", "helium.exe")
else:
out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, errors="replace", timeout=5)
names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge", "helium")
return any(n.lower() in out.lower() for n in names)
except Exception:
return False
_BROWSER_LAUNCH = (
# (profile-dir fragment, macOS app name, POSIX commands, Windows `start` target)
("chrome canary", "Google Chrome Canary", ("google-chrome-canary",), "chrome"),
("chromium", "Chromium", ("chromium", "chromium-browser"), "chromium"),
("chrome", "Google Chrome", ("google-chrome-stable", "google-chrome"), "chrome"),
("edge", "Microsoft Edge", ("microsoft-edge", "microsoft-edge-stable"), "msedge"),
("brave", "Brave Browser", ("brave-browser", "brave"), "brave"),
("arc", "Arc", (), None),
("dia", "Dia", (), None),
("comet", "Comet", (), None),
)
_DEFAULT_LAUNCH = (
"Google Chrome",
("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "microsoft-edge"),
"chrome",
)
def _browser_launch_spec(base):
"""(mac app, posix commands, windows target) for the browser w profile dir"""
tail = "/".join(p.lower() for p in Path(base).parts[-2:])
for frag, mac_app, posix_cmds, win_target in _BROWSER_LAUNCH:
if frag in tail:
return (mac_app, posix_cmds, win_target)
return _DEFAULT_LAUNCH
def _profile_directory_args(base):
"""Relaunch skips Chrome's profile picker"""
if not base:
return []
try:
state = json.loads((Path(base) / "Local State").read_text(encoding="utf-8", errors="replace"))
last = ((state.get("profile") or {}).get("last_used")) or "Default"
except (OSError, ValueError, AttributeError):
last = "Default"
if not isinstance(last, str) or not (Path(base) / last).is_dir():
return []
return [f"--profile-directory={last}"]
def _launch_browser():
"""Prefers the browser whose profile already has perm box checked"""
import platform, shutil, subprocess
from .daemon import PROFILES, remote_debugging_toggle_profiles
for key in ("BH_CHROME_PATH", "CHROME_PATH"):
raw = (os.environ.get(key) or "").strip()
if raw and Path(raw).expanduser().is_file():
try:
subprocess.Popen(
[str(Path(raw).expanduser())],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
)
return True
except (OSError, subprocess.SubprocessError):
# A path that exists but can't execute (permissions, wrong arch)
# must fall through to normal discovery, not abort
continue
enabled = remote_debugging_toggle_profiles()
base = enabled[0] if enabled else next((b for b in PROFILES if (b / "Local State").exists()), None)
mac_app, posix_cmds, win_target = _browser_launch_spec(base) if base else _DEFAULT_LAUNCH
profile_args = _profile_directory_args(base)
try:
system = platform.system()
if system == "Darwin":
cmd = ["open", "-a", mac_app] + (["--args"] + profile_args if profile_args else [])
r = subprocess.run(cmd, timeout=10, check=False, capture_output=True)
if r.returncode != 0 and mac_app != "Google Chrome":
# Different app → its profile dir may not match; launch plain
r = subprocess.run(["open", "-a", "Google Chrome"], timeout=10, check=False, capture_output=True)
return r.returncode == 0
if system == "Windows":
# `start <name>` resolves browsers via App Paths without knowing the install dir
subprocess.Popen(["cmd", "/c", "start", "", win_target or "chrome"] + profile_args, **ipc.spawn_kwargs())
return True
for cmd in posix_cmds or _DEFAULT_LAUNCH[1]:
w = shutil.which(cmd)
if w:
subprocess.Popen([w] + profile_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs())
return True
return False
except (OSError, subprocess.SubprocessError):
return False
def _open_chrome_inspect():
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
import platform, subprocess, webbrowser
url = "chrome://inspect/#remote-debugging"
if platform.system() == "Darwin":
try:
r = subprocess.run([
"osascript",
"-e", 'tell application "Google Chrome" to activate',
"-e", f'tell application "Google Chrome" to open location "{url}"',
], timeout=5, check=False, capture_output=True)
if r.returncode == 0:
return True
except Exception:
pass
try:
return bool(webbrowser.open(url, new=2))
except Exception:
return False
INSPECT_REOPEN_TTL = 180.0 # seconds open new chrome://inspect tab
def _open_chrome_inspect_once():
"""Open chrome://inspect at most once per INSPECT_REOPEN_TTL across invocations"""
marker = paths.inspect_marker()
try:
if time.time() - marker.stat().st_mtime < INSPECT_REOPEN_TTL:
return
except OSError:
pass
if not _open_chrome_inspect():
return
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.touch()
except OSError:
pass
def run_doctor():
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
import platform, sys
cur = _version()
mode = _install_mode()
chrome = _chrome_running()
daemon = daemon_alive()
connections = browser_connections()
try:
auth_state = auth.auth_status()
except (auth.AuthError, OSError) as e:
auth_state = {"status": "error", "source": None, "reason": str(e)}
cloud_auth = auth_state.get("status") == "authenticated"
latest = _latest_release_tag()
# Only claim an update when we know the installed version — `cur or "(unknown)"`
# for display would otherwise be parsed as (0,) and flag every latest as newer.
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
cur_display = cur or "(unknown)"
doc_url = _snap_linux_headless_doc_url()
def row(label, ok, detail=""):
mark = "ok " if ok else "FAIL"
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
print("browser-harness doctor")
print(f" platform {platform.system()} {platform.release()}")
print(f" python {sys.version.split()[0]}")
print(f" version {cur_display} ({mode})")
if latest:
print(f" latest release {latest}" + (" (update available)" if newer else ""))
else:
print(" latest release (could not reach PyPI)")
if platform.system() == "Linux":
bname, bpath = _doctor_probe_chrome_binary_for_snap()
if bname and bpath and _is_snap_browser(bpath):
print("[snap-detect]")
print(f"Browser: {bname} (snap) — WARNING: Snap confinement prevents CDP binding.")
print(f" Fix: Install Chrome natively (see docs/snap-linux-headless.md)")
print(f" Docs: {doc_url}")
row("chrome running", chrome, "" if chrome else "start chrome/edge")
row("daemon alive", daemon, "" if daemon else "see install.md")
row("active browser connections", bool(connections), str(len(connections)))
for conn in connections:
page = conn.get("page")
if page:
title = _doctor_short_text(page["title"])
url = _doctor_short_text(page["url"])
print(f" {conn['name']} — active page: {title} — {url}")
else:
print(f" {conn['name']} — active page: (no real page)")
row("Browser Use cloud auth", cloud_auth, auth_state.get("source") or auth_state.get("reason") or "optional: browser-harness auth login")
# Core health = chrome + daemon. Cloud auth is optional.
return 0 if (chrome and daemon) else 1
def _prompt_yes(question, default_yes=True, yes=False):
if yes:
return True
suffix = "[Y/n]" if default_yes else "[y/N]"
try:
ans = input(f"{question} {suffix} ").strip().lower()
except EOFError:
return default_yes
if not ans:
return default_yes
return ans.startswith("y")
def run_update(yes=False):
"""Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
Exit 0 on success, non-zero on failure."""
import subprocess, sys
cur, latest, newer = check_for_update()
# Only short-circuit as "up to date" when we actually know the installed
# version. Otherwise `newer=False` just means "couldn't compare" — proceed.
if cur and latest and not newer:
print(f"browser-harness is up to date ({cur}).")
return 0
if cur and latest:
print(f"updating browser-harness: {cur} -> {latest}")
elif latest:
print(f"installed version unknown; will try to update to {latest}.")
else:
print("could not reach PyPI; will try to update anyway.")
mode = _install_mode()
if mode == "git":
repo = _repo_dir()
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
if status.returncode != 0:
print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
return 1
if status.stdout.strip():
print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
return 1
r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
if r.returncode != 0:
return r.returncode
elif mode == "pypi":
tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
if tool_upgrade.returncode != 0:
return tool_upgrade.returncode
else:
print("unknown install mode; can't auto-update.", file=sys.stderr)
return 1
# Invalidate banner/tag cache so the new version doesn't keep nagging.
cache = _cache_read()
cache.pop("banner_shown_on", None)
_cache_write(cache)
if daemon_alive():
if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
restart_daemon()
print("daemon stopped; it will auto-restart on next `browser-harness` call.")
else:
print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
print("update complete.")
return 0