按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
392 lines
13 KiB
Python
392 lines
13 KiB
Python
import os, sys, time, urllib.request
|
|
from io import StringIO
|
|
|
|
# Windows default stdout/stderr encoding is cp1252
|
|
# which can't encode the 🐴 marker helpers prepend to tab titles (or anything
|
|
# else outside the locale charset). Force UTF-8 so `print(page_info())` and
|
|
# tracebacks carrying page titles don't UnicodeEncodeError on Windows. #124(4).
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
if hasattr(_stream, "reconfigure"):
|
|
try: _stream.reconfigure(encoding="utf-8", errors="replace")
|
|
except Exception: pass
|
|
|
|
from .admin import (
|
|
_version,
|
|
NAME,
|
|
daemon_alive,
|
|
daemon_browser_kind,
|
|
ensure_daemon,
|
|
list_cloud_profiles,
|
|
list_local_profiles,
|
|
print_update_banner,
|
|
restart_daemon,
|
|
run_doctor,
|
|
run_doctor_fix_snap,
|
|
run_update,
|
|
start_remote_daemon,
|
|
stop_remote_daemon,
|
|
sync_local_profile,
|
|
)
|
|
from . import auth, recorder, telemetry
|
|
from .helpers import *
|
|
|
|
HELP = """Browser Harness
|
|
|
|
Read SKILL.md for the default workflow and examples.
|
|
|
|
Typical usage:
|
|
browser-harness <<'PY'
|
|
ensure_real_tab()
|
|
print(page_info())
|
|
PY
|
|
|
|
Helpers are pre-imported. The daemon auto-starts and connects to the running browser.
|
|
|
|
Commands:
|
|
browser-harness --version print the installed version
|
|
browser-harness --doctor diagnose install, daemon, and browser state
|
|
browser-harness doctor same as --doctor
|
|
browser-harness doctor --fix-snap print how to fix Snap Chromium blocking CDP (Linux)
|
|
browser-harness auth login sign in to Browser Use Cloud for cloud browsers
|
|
browser-harness auth login --device-code sign in from SSH/headless environments
|
|
browser-harness auth status show Browser Use Cloud auth state
|
|
browser-harness auth logout remove stored Browser Use Cloud auth
|
|
browser-harness skill print the browser-harness skill text
|
|
browser-harness recordings show recording status and recent sessions
|
|
browser-harness recordings --latest print the newest recording directory
|
|
browser-harness recordings enable save browser actions locally by default
|
|
browser-harness recordings disable stop saving browser actions by default
|
|
browser-harness video init <recording> prepare a recording for editing
|
|
browser-harness video review <recording> compile and review the video
|
|
browser-harness video export <recording> --reviewed export a verified MP4
|
|
browser-harness telemetry status show anonymous telemetry opt-out state
|
|
browser-harness --update [-y] pull the latest version (agents: pass -y)
|
|
browser-harness --reload stop the daemon so next call picks up code changes
|
|
"""
|
|
|
|
USAGE = """Usage:
|
|
browser-harness <<'PY'
|
|
print(page_info())
|
|
PY
|
|
"""
|
|
|
|
|
|
# Probe /json/version (not a bare TCP connect) so a non-Chrome process bound to
|
|
# 9222/9223 doesn't masquerade as Chrome and skip the cloud bootstrap. Mirrors
|
|
# daemon.py's fallback probe.
|
|
def _local_chrome_listening():
|
|
for port in (9222, 9223):
|
|
try:
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close()
|
|
return True
|
|
except OSError: pass
|
|
return False
|
|
|
|
|
|
# BU_CDP_URL / BU_CDP_WS are documented to override local Chrome discovery
|
|
# (install.md:58-59), so they must also block cloud auto-bootstrap. Without this
|
|
# guard, start_remote_daemon() in admin.py overwrites BU_CDP_WS in the daemon
|
|
# env with a cloud WebSocket URL, silently replacing the user's explicit endpoint
|
|
# *and* billing them for a cloud browser they never asked for.
|
|
def _explicit_cdp_configured():
|
|
return bool(os.environ.get("BU_CDP_URL") or os.environ.get("BU_CDP_WS"))
|
|
|
|
|
|
def _cloud_auth_configured():
|
|
try:
|
|
auth.get_browser_use_api_key()
|
|
return True
|
|
except (auth.CloudAuthRequired, auth.AuthError, OSError):
|
|
return False
|
|
|
|
|
|
def _print_skill():
|
|
from importlib import resources
|
|
# SKILL.md is UTF-8 (contains emoji); locale-codec read crashes on gbk Windows
|
|
print(resources.files("browser_harness").joinpath("SKILL.md").read_text(encoding="utf-8"), end="")
|
|
|
|
|
|
def _telemetry_command(args):
|
|
if not args:
|
|
return "script"
|
|
first = args[0]
|
|
if first in {"-h", "--help"}:
|
|
return "help"
|
|
if first == "--version":
|
|
return "version"
|
|
if first in {"--doctor", "doctor"}:
|
|
return "doctor"
|
|
if first == "--update":
|
|
return "update"
|
|
if first == "--reload":
|
|
return "reload"
|
|
if first == "--debug-clicks":
|
|
return "debug-clicks"
|
|
if first in {"auth", "skill", "recordings", "telemetry", "video"}:
|
|
return first
|
|
return "usage"
|
|
|
|
|
|
def _exit_code(result) -> int:
|
|
if result is None:
|
|
return 0
|
|
if isinstance(result, int):
|
|
return result
|
|
return 1
|
|
|
|
_MAX_TRACED_STEPS = 500
|
|
_MAX_STEP_ARGS_LENGTH = 300
|
|
_helper_trace = []
|
|
_helper_call_count = 0
|
|
|
|
|
|
def _step_args(args, kwargs):
|
|
parts = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]
|
|
return ", ".join(parts)[:_MAX_STEP_ARGS_LENGTH]
|
|
|
|
|
|
def _traced(name, fn):
|
|
import functools
|
|
|
|
@functools.wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
global _helper_call_count
|
|
_helper_call_count += 1
|
|
entry = {"helper": name, "args": _step_args(args, kwargs)}
|
|
if len(_helper_trace) < _MAX_TRACED_STEPS:
|
|
_helper_trace.append(entry)
|
|
step_start = time.monotonic()
|
|
try:
|
|
result = fn(*args, **kwargs)
|
|
except BaseException as exc:
|
|
entry["duration_seconds"] = round(time.monotonic() - step_start, 3)
|
|
entry["error"] = str(exc)[:300]
|
|
raise
|
|
entry["duration_seconds"] = round(time.monotonic() - step_start, 3)
|
|
recorder.observe(name, args, kwargs, entry["duration_seconds"])
|
|
return result
|
|
|
|
wrapper.__bh_traced__ = True
|
|
return wrapper
|
|
|
|
|
|
def _install_helper_trace():
|
|
from . import helpers
|
|
|
|
g = globals()
|
|
for name in dir(helpers):
|
|
if name.startswith("_"):
|
|
continue
|
|
fn = g.get(name)
|
|
if callable(fn) and not isinstance(fn, type) and not getattr(fn, "__bh_traced__", False):
|
|
g[name] = _traced(name, fn)
|
|
|
|
|
|
_MAX_OUTPUT_LENGTH = 20_000
|
|
|
|
|
|
class _StreamTail:
|
|
"""Pass-through stream wrapper that remembers the tail and total length."""
|
|
|
|
def __init__(self, wrapped, limit=500):
|
|
self._wrapped = wrapped
|
|
self._limit = limit
|
|
self.tail = ""
|
|
self.length = 0
|
|
|
|
def write(self, text):
|
|
text = str(text)
|
|
self.length += len(text)
|
|
self.tail = (self.tail + text)[-self._limit :]
|
|
return self._wrapped.write(text)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._wrapped, name)
|
|
|
|
|
|
def _read_task(args):
|
|
if args and args[0] == "--debug-clicks":
|
|
args = args[1:]
|
|
if args or sys.stdin.isatty():
|
|
return None
|
|
code = sys.stdin.read()
|
|
sys.stdin = StringIO(code)
|
|
return code
|
|
|
|
|
|
def _traced_steps():
|
|
return _helper_trace or None
|
|
|
|
|
|
def _telemetry_browser(task):
|
|
"""'cloud' | 'cdp' | 'local', self-reported by the daemon the task ran on.
|
|
None when no browser was involved (non-script commands, daemon never up)."""
|
|
if not task or not telemetry.is_enabled():
|
|
return None
|
|
try:
|
|
return daemon_browser_kind()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def main():
|
|
global _helper_call_count
|
|
args = sys.argv[1:]
|
|
if args and args[0] == "telemetry":
|
|
sys.exit(telemetry.run_telemetry_cli(args[1:]))
|
|
_helper_trace.clear()
|
|
_helper_call_count = 0
|
|
start_time = time.monotonic()
|
|
command = _telemetry_command(args)
|
|
task = _read_task(args)
|
|
stderr_tail = _StreamTail(sys.stderr)
|
|
stdout_tail = _StreamTail(sys.stdout, limit=_MAX_OUTPUT_LENGTH)
|
|
sys.stderr = stderr_tail
|
|
sys.stdout = stdout_tail
|
|
try:
|
|
_run(args)
|
|
except SystemExit as exc:
|
|
code = _exit_code(exc.code)
|
|
telemetry.capture_cli_event(
|
|
action="error" if code else "completed",
|
|
command=command,
|
|
task=task,
|
|
browser=_telemetry_browser(task),
|
|
output=stdout_tail.tail or None,
|
|
output_length=stdout_tail.length or None,
|
|
steps=_traced_steps(),
|
|
step_count=_helper_call_count or None,
|
|
duration_seconds=time.monotonic() - start_time,
|
|
exit_code=code,
|
|
error_message=str(exc.code) if isinstance(exc.code, str) else (stderr_tail.tail.strip() or None) if code else None,
|
|
)
|
|
raise
|
|
except Exception as exc:
|
|
telemetry.capture_cli_event(
|
|
action="error",
|
|
command=command,
|
|
task=task,
|
|
browser=_telemetry_browser(task),
|
|
output=stdout_tail.tail or None,
|
|
output_length=stdout_tail.length or None,
|
|
steps=_traced_steps(),
|
|
step_count=_helper_call_count or None,
|
|
duration_seconds=time.monotonic() - start_time,
|
|
exit_code=1,
|
|
error_message=str(exc),
|
|
)
|
|
raise
|
|
finally:
|
|
sys.stderr = stderr_tail._wrapped
|
|
sys.stdout = stdout_tail._wrapped
|
|
telemetry.capture_cli_event(
|
|
action="completed",
|
|
command=command,
|
|
task=task,
|
|
browser=_telemetry_browser(task),
|
|
output=stdout_tail.tail or None,
|
|
output_length=stdout_tail.length or None,
|
|
steps=_traced_steps(),
|
|
step_count=_helper_call_count or None,
|
|
duration_seconds=time.monotonic() - start_time,
|
|
exit_code=0,
|
|
)
|
|
|
|
|
|
def _run(args):
|
|
if args and args[0] in {"-h", "--help"}:
|
|
print(HELP)
|
|
return
|
|
if args and args[0] == "--version":
|
|
print(_version() or "unknown")
|
|
return
|
|
if args and args[0] == "--doctor":
|
|
sys.exit(run_doctor())
|
|
if args and args[0] == "doctor":
|
|
rest = args[1:]
|
|
if rest == ["--fix-snap"]:
|
|
sys.exit(run_doctor_fix_snap())
|
|
if rest:
|
|
print("usage: browser-harness doctor [--fix-snap]", file=sys.stderr)
|
|
sys.exit(2)
|
|
sys.exit(run_doctor())
|
|
if args and args[0] == "auth":
|
|
sys.exit(auth.run_auth_cli(args[1:]))
|
|
if args and args[0] == "skill":
|
|
if len(args) != 1:
|
|
print("usage: browser-harness skill", file=sys.stderr)
|
|
sys.exit(2)
|
|
_print_skill()
|
|
return
|
|
if args and args[0] == "recordings":
|
|
rest = args[1:]
|
|
if rest == ["--latest"]:
|
|
latest = recorder.latest_recording()
|
|
if latest is None:
|
|
print("no recordings found", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(latest)
|
|
return
|
|
if rest in (["enable"], ["disable"]):
|
|
enabled = rest == ["enable"]
|
|
recorder.set_auto_recording(enabled)
|
|
print(f"auto-recording preference {'enabled' if enabled else 'disabled'}")
|
|
return
|
|
if rest:
|
|
print("usage: browser-harness recordings [--latest|enable|disable]", file=sys.stderr)
|
|
sys.exit(2)
|
|
enabled, source = recorder.auto_recording_setting()
|
|
print(f"auto-recording: {'on' if enabled else 'off'} ({source})")
|
|
active = recorder.recording_dir()
|
|
print(f"active: {active or 'none'}")
|
|
recent = recorder.recordings()
|
|
print(f"latest: {recent[0] if recent else 'none'}")
|
|
return
|
|
if args and args[0] == "video":
|
|
from . import video
|
|
|
|
sys.exit(video.run_cli(args[1:]))
|
|
if args and args[0] == "--update":
|
|
yes = any(a in {"-y", "--yes"} for a in args[1:])
|
|
sys.exit(run_update(yes=yes))
|
|
if args and args[0] == "--reload":
|
|
restart_daemon()
|
|
print("daemon stopped — will restart fresh on next call")
|
|
return
|
|
if args and args[0] == "--debug-clicks":
|
|
os.environ["BH_DEBUG_CLICKS"] = "1"
|
|
args = args[1:]
|
|
if not args and not sys.stdin.isatty():
|
|
code = sys.stdin.read()
|
|
if not code.strip():
|
|
sys.exit(USAGE)
|
|
else:
|
|
sys.exit(USAGE)
|
|
print_update_banner()
|
|
# Auto-bootstrap a cloud browser is opt-in via BU_AUTOSPAWN — BROWSER_USE_API_KEY alone
|
|
# is not enough, since the key is commonly set for unrelated reasons (profile sync,
|
|
# cloud API calls, parent agents managing their own session). An explicit BU_CDP_URL
|
|
# or BU_CDP_WS also blocks the spawn so we honour the precedence install.md promises.
|
|
cloud_admin = code.lstrip().startswith(("start_remote_daemon(", "stop_remote_daemon("))
|
|
if not cloud_admin:
|
|
if (
|
|
not daemon_alive()
|
|
and not _local_chrome_listening()
|
|
and not _explicit_cdp_configured()
|
|
and _cloud_auth_configured()
|
|
and os.environ.get("BU_AUTOSPAWN")
|
|
):
|
|
start_remote_daemon(NAME)
|
|
try:
|
|
ensure_daemon()
|
|
except RuntimeError as e:
|
|
# Setup/permission errors are instructions for calling agent
|
|
print(f"browser-harness: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
_install_helper_trace()
|
|
exec(code, globals())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|