按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
544 lines
18 KiB
Python
544 lines
18 KiB
Python
"""Browser Use Cloud auth for browser-harness.
|
|
|
|
The model-facing contract stays small: cloud browser startup either has a key
|
|
or tells the agent to run `browser-harness auth login`. OAuth details live here.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
import argparse
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import stat
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import webbrowser
|
|
|
|
from . import paths
|
|
|
|
|
|
AUTH_BASE = "https://api.browser-use.com"
|
|
# Browser Use currently exposes this registered CLI OAuth client. Keep an env
|
|
# escape hatch so a dedicated browser-harness client can be used once issued.
|
|
DEFAULT_CLIENT_ID = "browser-use-terminal"
|
|
CALLBACK_PATH = "/browser-use-cloud/callback"
|
|
AUTH_TIMEOUT_SECONDS = 600
|
|
|
|
|
|
class CloudAuthRequired(RuntimeError):
|
|
def __init__(self):
|
|
super().__init__("cloud-auth-required: run `browser-harness auth login`")
|
|
|
|
|
|
class AuthError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class PendingCallback:
|
|
state: str
|
|
code: str | None = None
|
|
error: str | None = None
|
|
error_description: str | None = None
|
|
complete: bool = False
|
|
|
|
|
|
@dataclass
|
|
class BrowserAuthStart:
|
|
server: HTTPServer
|
|
callback: PendingCallback
|
|
redirect_uri: str
|
|
verifier: str
|
|
auth_url: str
|
|
expires_in: int | None
|
|
opened: bool = False
|
|
|
|
|
|
@dataclass
|
|
class DeviceAuthStart:
|
|
device_code: str
|
|
user_code: str
|
|
verification_uri: str
|
|
verification_uri_complete: str | None = None
|
|
expires_in: int | None = None
|
|
interval: int = 5
|
|
opened: bool = False
|
|
|
|
@property
|
|
def open_uri(self) -> str:
|
|
return self.verification_uri_complete or self.verification_uri
|
|
|
|
|
|
@dataclass
|
|
class AuthRecord:
|
|
api_key: str
|
|
api_key_id: str | None = None
|
|
project_id: str | None = None
|
|
expires_at: str | None = None
|
|
scopes: list[str] = field(default_factory=list)
|
|
source: str = "oauth"
|
|
|
|
@classmethod
|
|
def from_token_response(cls, data: dict, *, source: str = "oauth") -> "AuthRecord":
|
|
api_key = data.get("api_key")
|
|
if not api_key:
|
|
raise AuthError("auth token response did not include an api_key")
|
|
scopes = data.get("scopes") or []
|
|
if not isinstance(scopes, list):
|
|
scopes = []
|
|
return cls(
|
|
api_key=api_key,
|
|
api_key_id=data.get("api_key_id"),
|
|
project_id=data.get("project_id"),
|
|
expires_at=data.get("expires_at"),
|
|
scopes=[str(s) for s in scopes],
|
|
source=source,
|
|
)
|
|
|
|
def to_storage(self) -> dict:
|
|
return {
|
|
"api_key": self.api_key,
|
|
"api_key_id": self.api_key_id,
|
|
"project_id": self.project_id,
|
|
"expires_at": self.expires_at,
|
|
"scopes": self.scopes,
|
|
"source": self.source,
|
|
}
|
|
|
|
|
|
def auth_base() -> str:
|
|
return (os.environ.get("BROWSER_USE_CLOUD_API_URL") or AUTH_BASE).rstrip("/")
|
|
|
|
|
|
def client_id() -> str:
|
|
return os.environ.get("BROWSER_HARNESS_OAUTH_CLIENT_ID") or DEFAULT_CLIENT_ID
|
|
|
|
|
|
def auth_path() -> Path:
|
|
override = os.environ.get("BH_AUTH_PATH")
|
|
if override:
|
|
return Path(override).expanduser()
|
|
return paths.config_dir() / "auth.json"
|
|
|
|
|
|
def load_auth_file(path: Path | None = None) -> dict:
|
|
path = path or auth_path()
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return {}
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
raise AuthError(f"auth file is not valid JSON: {path}") from e
|
|
|
|
|
|
def save_auth_record(record: AuthRecord, path: Path | None = None) -> None:
|
|
path = path or auth_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
_chmod_private(path.parent, directory=True)
|
|
existing = load_auth_file(path)
|
|
existing["browser_use"] = record.to_storage()
|
|
tmp = path.with_name(path.name + ".tmp")
|
|
_write_private_json(tmp, existing)
|
|
os.replace(tmp, path)
|
|
_chmod_private(path)
|
|
|
|
|
|
def clear_auth(path: Path | None = None) -> bool:
|
|
path = path or auth_path()
|
|
data = load_auth_file(path)
|
|
existed = bool(data.get("browser_use"))
|
|
data.pop("browser_use", None)
|
|
if data:
|
|
tmp = path.with_name(path.name + ".tmp")
|
|
_write_private_json(tmp, data)
|
|
os.replace(tmp, path)
|
|
_chmod_private(path)
|
|
else:
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
return existed
|
|
|
|
|
|
def stored_auth_record(path: Path | None = None) -> dict | None:
|
|
data = load_auth_file(path)
|
|
value = data.get("browser_use")
|
|
return value if isinstance(value, dict) else None
|
|
|
|
|
|
def get_browser_use_api_key() -> str:
|
|
env_key = os.environ.get("BROWSER_USE_API_KEY")
|
|
if env_key:
|
|
return env_key
|
|
stored = stored_auth_record()
|
|
key = stored.get("api_key") if stored else None
|
|
if key:
|
|
return str(key)
|
|
raise CloudAuthRequired()
|
|
|
|
|
|
def auth_status() -> dict:
|
|
if os.environ.get("BROWSER_USE_API_KEY"):
|
|
return {"status": "authenticated", "source": "env", "path": str(auth_path())}
|
|
stored = stored_auth_record()
|
|
if not stored or not stored.get("api_key"):
|
|
return {"status": "missing", "source": None, "path": str(auth_path())}
|
|
return {"status": "authenticated", "source": "stored", "path": str(auth_path())}
|
|
|
|
|
|
def pkce_pair() -> tuple[str, str]:
|
|
verifier = secrets.token_urlsafe(48)
|
|
digest = hashlib.sha256(verifier.encode()).digest()
|
|
challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
return verifier, challenge
|
|
|
|
|
|
def start_browser_auth(*, open_url=True, timeout=AUTH_TIMEOUT_SECONDS) -> BrowserAuthStart:
|
|
verifier, challenge = pkce_pair()
|
|
state = secrets.token_urlsafe(32)
|
|
callback = PendingCallback(state=state)
|
|
server = _callback_server(callback)
|
|
host, port = server.server_address
|
|
redirect_uri = f"http://{host}:{port}{CALLBACK_PATH}"
|
|
req = {
|
|
"client_id": client_id(),
|
|
"response_type": "code",
|
|
"redirect_uri": redirect_uri,
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"state": state,
|
|
"device_name": os.environ.get("BH_DEVICE_NAME") or "browser-harness",
|
|
}
|
|
try:
|
|
data = _post_json(f"{auth_base()}/cloud/cli-auth/browser", req)
|
|
except BaseException:
|
|
server.server_close()
|
|
raise
|
|
auth_url = data.get("authorization_uri") or data.get("auth_url")
|
|
if not auth_url:
|
|
server.server_close()
|
|
raise AuthError("auth start response did not include authorization_uri")
|
|
expires_in = _int_or_none(data.get("expires_in"))
|
|
opened = False
|
|
if open_url:
|
|
try:
|
|
opened = bool(webbrowser.open(auth_url))
|
|
except Exception:
|
|
opened = False
|
|
return BrowserAuthStart(
|
|
server=server,
|
|
callback=callback,
|
|
redirect_uri=redirect_uri,
|
|
verifier=verifier,
|
|
auth_url=auth_url,
|
|
expires_in=expires_in,
|
|
opened=opened,
|
|
)
|
|
|
|
|
|
def complete_browser_auth(start: BrowserAuthStart, *, timeout=AUTH_TIMEOUT_SECONDS) -> AuthRecord:
|
|
deadline = time.time() + timeout
|
|
start.server.timeout = 0.5
|
|
try:
|
|
while not start.callback.complete and time.time() < deadline:
|
|
start.server.handle_request()
|
|
finally:
|
|
start.server.server_close()
|
|
if not start.callback.complete:
|
|
raise AuthError("timed out waiting for browser auth callback")
|
|
if start.callback.error:
|
|
detail = f": {start.callback.error_description}" if start.callback.error_description else ""
|
|
raise AuthError(f"auth failed: {start.callback.error}{detail}")
|
|
if not start.callback.code:
|
|
raise AuthError("auth callback did not include a code")
|
|
token = _exchange_authorization_code(start.callback.code, start.redirect_uri, start.verifier)
|
|
record = AuthRecord.from_token_response(token)
|
|
save_auth_record(record)
|
|
return record
|
|
|
|
|
|
def browser_login(*, open_url=True, json_output=False, timeout=AUTH_TIMEOUT_SECONDS) -> AuthRecord:
|
|
start = start_browser_auth(open_url=open_url, timeout=timeout)
|
|
if json_output:
|
|
print(json.dumps({
|
|
"status": "needs_user_auth",
|
|
"auth_url": start.auth_url,
|
|
"callback": start.redirect_uri,
|
|
"expires_in": start.expires_in,
|
|
"opened": start.opened,
|
|
}), flush=True)
|
|
else:
|
|
print("Open this URL to sign in to Browser Use Cloud:")
|
|
print(start.auth_url, flush=True)
|
|
if start.opened:
|
|
print("Waiting for login to complete...", flush=True)
|
|
else:
|
|
print("Waiting for login to complete after you open the URL...", flush=True)
|
|
record = complete_browser_auth(start, timeout=timeout)
|
|
if json_output:
|
|
print(json.dumps(_stored_success_output()), flush=True)
|
|
else:
|
|
print("Browser Use Cloud auth stored.")
|
|
return record
|
|
|
|
|
|
def start_device_auth(*, open_url=True) -> DeviceAuthStart:
|
|
data = _post_json(
|
|
f"{auth_base()}/cloud/cli-auth/device",
|
|
{"client_id": client_id(), "device_name": os.environ.get("BH_DEVICE_NAME") or "browser-harness"},
|
|
)
|
|
device_code = data.get("device_code")
|
|
user_code = data.get("user_code")
|
|
verification_uri = data.get("verification_uri") or data.get("verification_url")
|
|
if not device_code or not user_code or not verification_uri:
|
|
raise AuthError("device auth response missing device_code, user_code, or verification_uri")
|
|
opened = False
|
|
open_uri = data.get("verification_uri_complete") or verification_uri
|
|
if open_url:
|
|
try:
|
|
opened = bool(webbrowser.open(open_uri))
|
|
except Exception:
|
|
opened = False
|
|
return DeviceAuthStart(
|
|
device_code=device_code,
|
|
user_code=user_code,
|
|
verification_uri=verification_uri,
|
|
verification_uri_complete=data.get("verification_uri_complete"),
|
|
expires_in=_int_or_none(data.get("expires_in")),
|
|
interval=max(1, _int_or_none(data.get("interval")) or 5),
|
|
opened=opened,
|
|
)
|
|
|
|
|
|
def complete_device_auth(start: DeviceAuthStart, *, timeout: int | None = None) -> AuthRecord:
|
|
deadline = time.time() + (timeout or start.expires_in or AUTH_TIMEOUT_SECONDS)
|
|
interval = start.interval
|
|
while time.time() < deadline:
|
|
try:
|
|
token = _post_json(f"{auth_base()}/cloud/cli-auth/token", {
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
"device_code": start.device_code,
|
|
"client_id": client_id(),
|
|
})
|
|
record = AuthRecord.from_token_response(token)
|
|
save_auth_record(record)
|
|
return record
|
|
except AuthError as e:
|
|
err = _auth_error_code(str(e))
|
|
if err == "authorization_pending":
|
|
time.sleep(interval)
|
|
continue
|
|
if err == "slow_down":
|
|
interval += 5
|
|
time.sleep(interval)
|
|
continue
|
|
raise
|
|
raise AuthError("timed out waiting for device auth")
|
|
|
|
|
|
def device_login(*, open_url=True, json_output=False) -> AuthRecord:
|
|
start = start_device_auth(open_url=open_url)
|
|
if json_output:
|
|
print(json.dumps({
|
|
"status": "needs_user_auth",
|
|
"verification_uri": start.verification_uri,
|
|
"verification_uri_complete": start.verification_uri_complete,
|
|
"user_code": start.user_code,
|
|
"expires_in": start.expires_in,
|
|
"opened": start.opened,
|
|
}), flush=True)
|
|
else:
|
|
print("Open this URL to sign in to Browser Use Cloud:")
|
|
print(start.open_uri, flush=True)
|
|
print(f"Code: {start.user_code}", flush=True)
|
|
print("Waiting for login to complete...", flush=True)
|
|
record = complete_device_auth(start)
|
|
if json_output:
|
|
print(json.dumps(_stored_success_output()), flush=True)
|
|
else:
|
|
print("Browser Use Cloud auth stored.")
|
|
return record
|
|
|
|
|
|
def api_key_stdin_login(*, json_output=False, input_stream=None) -> AuthRecord:
|
|
key = _read_manual_api_key(input_stream)
|
|
record = AuthRecord(api_key=key, source="manual")
|
|
save_auth_record(record)
|
|
if json_output:
|
|
print(json.dumps(_stored_success_output()), flush=True)
|
|
else:
|
|
print("Browser Use Cloud API key stored.")
|
|
return record
|
|
|
|
|
|
def _exchange_authorization_code(code: str, redirect_uri: str, verifier: str) -> dict:
|
|
return _post_json(f"{auth_base()}/cloud/cli-auth/token", {
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
"code_verifier": verifier,
|
|
"client_id": client_id(),
|
|
})
|
|
|
|
|
|
def _callback_server(callback: PendingCallback) -> HTTPServer:
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self): # noqa: N802 - stdlib handler API
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path != CALLBACK_PATH:
|
|
self.send_error(404)
|
|
return
|
|
qs = urllib.parse.parse_qs(parsed.query)
|
|
state = _one(qs, "state")
|
|
if state != callback.state:
|
|
callback.error = "invalid_state"
|
|
callback.error_description = "OAuth callback state did not match"
|
|
else:
|
|
callback.code = _one(qs, "code")
|
|
callback.error = _one(qs, "error")
|
|
callback.error_description = _one(qs, "error_description")
|
|
callback.complete = True
|
|
body = b"<html><body><h1>Browser Use Cloud login complete</h1><p>You can close this tab.</p></body></html>"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, fmt, *args):
|
|
return
|
|
|
|
return HTTPServer(("127.0.0.1", 0), Handler)
|
|
|
|
|
|
def _post_json(url: str, payload: dict) -> dict:
|
|
req = urllib.request.Request(
|
|
url,
|
|
method="POST",
|
|
data=json.dumps(payload).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
return json.loads(resp.read() or b"{}")
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read() or b""
|
|
try:
|
|
data = json.loads(body or b"{}")
|
|
except json.JSONDecodeError:
|
|
data = {}
|
|
err = data.get("error") or data.get("state") or f"http_{e.code}"
|
|
desc = data.get("error_description") or data.get("reason") or data.get("message")
|
|
detail = f": {desc}" if desc else ""
|
|
raise AuthError(f"{err}{detail}") from e
|
|
except urllib.error.URLError as e:
|
|
raise AuthError(f"network error: {e.reason}") from e
|
|
|
|
|
|
def _read_manual_api_key(input_stream=None) -> str:
|
|
stream = input_stream or sys.stdin
|
|
if hasattr(stream, "isatty") and stream.isatty():
|
|
try:
|
|
key = getpass.getpass("Browser Use API key: ")
|
|
except EOFError as e:
|
|
raise AuthError("no API key provided") from e
|
|
else:
|
|
key = stream.read()
|
|
key = (key or "").strip()
|
|
if not key:
|
|
raise AuthError("no API key provided")
|
|
if len(key) < 20:
|
|
raise AuthError("API key looks too short")
|
|
return key
|
|
|
|
|
|
def _write_private_json(path: Path, data: dict) -> None:
|
|
raw = (json.dumps(data, indent=2) + "\n").encode()
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
fd = os.open(path, flags, stat.S_IRUSR | stat.S_IWUSR)
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(raw)
|
|
except BaseException:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _chmod_private(path: Path, *, directory=False) -> None:
|
|
mode = stat.S_IRWXU if directory else stat.S_IRUSR | stat.S_IWUSR
|
|
try:
|
|
os.chmod(path, mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _one(qs: dict[str, list[str]], key: str) -> str | None:
|
|
values = qs.get(key)
|
|
return values[0] if values else None
|
|
|
|
|
|
def _int_or_none(value) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _auth_error_code(message: str) -> str:
|
|
return message.split(":", 1)[0]
|
|
|
|
|
|
def _stored_success_output() -> dict:
|
|
return {"status": "stored", "path": str(auth_path())}
|
|
|
|
|
|
def run_auth_cli(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(prog="browser-harness auth")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
login = sub.add_parser("login")
|
|
login_mode = login.add_mutually_exclusive_group()
|
|
login_mode.add_argument("--device-code", action="store_true")
|
|
login_mode.add_argument("--api-key-stdin", action="store_true")
|
|
login.add_argument("--json", action="store_true")
|
|
login.add_argument("--no-open", action="store_true")
|
|
sub.add_parser("status")
|
|
sub.add_parser("logout")
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
if args.command == "login":
|
|
if args.api_key_stdin:
|
|
api_key_stdin_login(json_output=args.json)
|
|
elif args.device_code:
|
|
device_login(open_url=not args.no_open, json_output=args.json)
|
|
else:
|
|
browser_login(open_url=not args.no_open, json_output=args.json)
|
|
return 0
|
|
if args.command == "status":
|
|
print(json.dumps(auth_status(), indent=2))
|
|
return 0
|
|
if args.command == "logout":
|
|
removed = clear_auth()
|
|
print(json.dumps({"status": "logged-out" if removed else "missing", "path": str(auth_path())}, indent=2))
|
|
return 0
|
|
except (AuthError, CloudAuthRequired) as e:
|
|
if getattr(args, "json", False):
|
|
print(json.dumps({"status": "error", "reason": str(e)}), file=sys.stderr)
|
|
else:
|
|
print(str(e), file=sys.stderr)
|
|
return 1
|
|
return 2
|