Files
browser-use/browser_use/skill_cli/commands/cloud.py
Alezander9 76569995fd Improve OSS-to-cloud conversion: UTM tracking, better error messages, and cloud nudges
- Add UTM params to all cloud-bound links across README, CLI, and error messages
- Rewrite README Open Source vs Cloud section: position cloud browsers as
  recommended pairing for OSS users, remove separate Use Both section
- Rewrite error messages for use_cloud=True and ChatBrowserUse() to clearly
  state what is wrong and what to do next
- Add missing URLs: invalid API key now links to key page, insufficient
  credits now links to billing page
- Add cloud browser nudge on captcha detection (logger.warning)
- Add cloud browser nudge on local browser launch failure
2026-04-08 22:05:50 -07:00

695 lines
20 KiB
Python

"""Cloud API command — generic REST passthrough to Browser-Use Cloud.
Stdlib only. No async, no SDK, no heavy imports.
Usage:
browser-use cloud login <api-key>
browser-use cloud logout
browser-use cloud v2 GET /browsers
browser-use cloud v2 POST /tasks '{"task":"...","url":"https://..."}'
browser-use cloud v2 poll <task-id>
browser-use cloud v2 --help
"""
import json
import os
import sys
import time
import typing
import urllib.error
import urllib.request
from pathlib import Path
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_BASE_URL = 'https://api.browser-use.com'
_AUTH_HEADER = 'X-Browser-Use-API-Key'
def _get_base() -> str:
"""Get the API host URL. All paths are appended by callers."""
return os.environ.get('BROWSER_USE_CLOUD_BASE_URL', _DEFAULT_BASE_URL).rstrip('/')
def _base_url(version: str) -> str:
"""Get versioned API URL: {base}/api/{version}"""
per_version = os.environ.get(f'BROWSER_USE_CLOUD_BASE_URL_{version.upper()}')
if per_version:
return per_version
return f'{_get_base()}/api/{version}'
def _spec_url(version: str) -> str:
per_version = os.environ.get(f'BROWSER_USE_OPENAPI_SPEC_URL_{version.upper()}')
if per_version:
return per_version
return f'{_get_base()}/api/{version}/openapi.json'
# ---------------------------------------------------------------------------
# API key persistence
# ---------------------------------------------------------------------------
def _get_config_path() -> Path:
from browser_use.skill_cli.utils import get_config_path
return get_config_path()
def _read_config() -> dict:
from browser_use.skill_cli.config import read_config
return read_config()
def _write_config(data: dict) -> None:
from browser_use.skill_cli.config import write_config
write_config(data)
def _get_api_key_or_none() -> str | None:
"""Return API key from CLI config file, or None if not found."""
from browser_use.skill_cli.config import get_config_value
val = get_config_value('api_key')
return str(val) if val is not None else None
def _get_api_key() -> str:
"""Return API key from config file. Exits with error if missing."""
key = _get_api_key_or_none()
if key:
return key
print('Error: No API key found.', file=sys.stderr)
if os.environ.get('BROWSER_USE_API_KEY'):
print(' Note: BROWSER_USE_API_KEY env var is set but not used by the CLI.', file=sys.stderr)
print(' Run: browser-use config set api_key "$BROWSER_USE_API_KEY"', file=sys.stderr)
else:
print(
'Already have an account? Get a key at: https://cloud.browser-use.com/settings?tab=api-keys&new=1&utm_source=oss&utm_medium=cli',
file=sys.stderr,
)
print(' Then run: browser-use cloud login <key>', file=sys.stderr)
print('No account? Run: browser-use cloud signup', file=sys.stderr)
print(' This creates an agent account you can claim later with: browser-use cloud signup --claim', file=sys.stderr)
sys.exit(1)
def _create_cloud_profile_inner(api_key: str) -> str:
"""Create a new cloud profile and save to config. Returns profile ID.
Raises RuntimeError on failure — safe to call from daemon context.
"""
body = json.dumps({'name': 'Browser Use CLI'}).encode()
status, resp = _http_request('POST', f'{_base_url("v2")}/profiles', body, api_key)
if status >= 400:
raise RuntimeError(f'Error creating cloud profile: HTTP {status}{resp}')
try:
data = json.loads(resp)
new_id = data['id']
except (json.JSONDecodeError, KeyError, TypeError):
raise RuntimeError(f'Unexpected response from cloud API: {resp}')
config = _read_config()
config['cloud_connect_profile_id'] = new_id
_write_config(config)
return new_id
def _create_cloud_profile() -> str:
"""Create a new cloud profile and save to config. Returns profile ID.
CLI entry point — exits on error.
"""
api_key = _get_api_key()
try:
return _create_cloud_profile_inner(api_key)
except RuntimeError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
def _get_or_create_cloud_profile() -> str:
"""Return cloud profile ID from config, creating one if missing. No validation HTTP call."""
config = _read_config()
profile_id = config.get('cloud_connect_profile_id')
if profile_id:
return profile_id
return _create_cloud_profile()
def _get_cloud_connect_proxy() -> str | None:
"""Return the cloud connect proxy country code from config."""
from browser_use.skill_cli.config import get_config_value
val = get_config_value('cloud_connect_proxy')
return str(val) if val is not None else None
def _get_cloud_connect_timeout() -> int | None:
"""Return the cloud connect timeout (minutes) from config."""
from browser_use.skill_cli.config import get_config_value
val = get_config_value('cloud_connect_timeout')
return int(val) if val is not None else None
def _save_api_key(key: str) -> None:
config = _read_config()
config['api_key'] = key
_write_config(config)
def _remove_api_key() -> bool:
config = _read_config()
if 'api_key' not in config:
return False
del config['api_key']
path = _get_config_path()
if config:
_write_config(config)
else:
path.unlink(missing_ok=True)
return True
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _http_request(method: str, url: str, body: bytes | None, api_key: str, timeout: float = 30.0) -> tuple[int, bytes]:
"""Fire an HTTP request. Returns (status_code, response_body)."""
headers = {_AUTH_HEADER: api_key}
if body is not None:
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except urllib.error.URLError as e:
print(f'Error: {e.reason}', file=sys.stderr)
sys.exit(1)
def _print_json(data: bytes, file: typing.TextIO | None = None) -> None:
"""Pretty-print JSON, raw fallback."""
out = file or sys.stdout
try:
parsed = json.loads(data)
print(json.dumps(parsed, indent=2), file=out)
except (json.JSONDecodeError, ValueError):
buf = out.buffer if hasattr(out, 'buffer') else sys.stdout.buffer
buf.write(data)
buf.write(b'\n')
buf.flush()
# ---------------------------------------------------------------------------
# OpenAPI help
# ---------------------------------------------------------------------------
def _fetch_spec(version: str) -> bytes | None:
url = _spec_url(version)
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.read()
except Exception:
return None
def _example_value(prop: dict, schemas: dict) -> object:
"""Generate a placeholder value for an OpenAPI property."""
if '$ref' in prop:
ref_name = prop['$ref'].rsplit('/', 1)[-1]
if ref_name in schemas:
return _generate_body_example_dict(ref_name, schemas)
return {}
t = prop.get('type', 'string')
fmt = prop.get('format', '')
enum = prop.get('enum')
if enum:
return enum[0]
if t == 'string':
if fmt == 'uri' or fmt == 'url':
return 'https://example.com'
if fmt == 'date-time':
return '2025-01-01T00:00:00Z'
if 'email' in fmt:
return 'user@example.com'
return '...'
if t == 'integer':
return 0
if t == 'number':
return 0.0
if t == 'boolean':
return False
if t == 'array':
items = prop.get('items', {})
return [_example_value(items, schemas)]
if t == 'object':
props = prop.get('properties', {})
return {k: _example_value(v, schemas) for k, v in props.items()}
return '...'
def _generate_body_example_dict(ref_name: str, schemas: dict) -> dict:
"""Build a compact example dict from a $ref schema."""
schema = schemas.get(ref_name, {})
props = schema.get('properties', {})
required = set(schema.get('required', []))
result = {}
# Required fields first, then sorted optional
for key in sorted(props, key=lambda k: (k not in required, k)):
result[key] = _example_value(props[key], schemas)
return result
def _generate_body_example(ref: str, schemas: dict) -> str:
"""Return compact JSON string for a $ref."""
ref_name = ref.rsplit('/', 1)[-1]
obj = _generate_body_example_dict(ref_name, schemas)
return json.dumps(obj, separators=(',', ':'))
def _find_body_ref(spec: dict, method: str, path: str) -> str | None:
"""Find the $ref for request body of a given method+path in spec."""
paths = spec.get('paths', {})
path_obj = paths.get(path, {})
method_obj = path_obj.get(method.lower(), {})
body = method_obj.get('requestBody', {})
content = body.get('content', {})
json_media = content.get('application/json', {})
schema = json_media.get('schema', {})
return schema.get('$ref')
def _match_path(spec_path: str, req_path: str) -> bool:
"""Match an OpenAPI template path against a concrete path.
E.g. /tasks/{task_id} matches /tasks/abc123
"""
spec_parts = spec_path.strip('/').split('/')
req_parts = req_path.strip('/').split('/')
if len(spec_parts) != len(req_parts):
return False
for sp, rp in zip(spec_parts, req_parts):
if sp.startswith('{') and sp.endswith('}'):
continue
if sp != rp:
return False
return True
def _find_body_example(spec: dict, method: str, path: str) -> str | None:
"""Find a body example for the given method+path, using template matching."""
schemas = spec.get('components', {}).get('schemas', {})
paths = spec.get('paths', {})
for spec_path in paths:
if _match_path(spec_path, path):
ref = _find_body_ref(spec, method, spec_path)
if ref:
return _generate_body_example(ref, schemas)
return None
def _format_openapi_help(spec_data: bytes) -> str:
"""Parse OpenAPI spec and render grouped endpoints."""
try:
spec = json.loads(spec_data)
except (json.JSONDecodeError, ValueError):
return ''
paths = spec.get('paths', {})
schemas = spec.get('components', {}).get('schemas', {})
info = spec.get('info', {})
lines: list[str] = []
title = info.get('title', 'API')
version = info.get('version', '')
lines.append(f'{title} {version}'.strip())
lines.append('')
# Group by tag
groups: dict[str, list[str]] = {}
for path, methods in sorted(paths.items()):
for method, details in sorted(methods.items()):
if method in ('parameters', 'summary', 'description'):
continue
tags = details.get('tags', ['Other'])
tag = tags[0] if tags else 'Other'
summary = details.get('summary', '')
# Build endpoint line
parts = [f' {method.upper():6s} {path}']
if summary:
parts.append(f' # {summary}')
# Parameters
params = details.get('parameters', [])
param_strs = []
for p in params:
name = p.get('name', '')
required = p.get('required', False)
marker = '*' if required else ''
param_strs.append(f'{name}{marker}')
if param_strs:
parts.append(f' params: {", ".join(param_strs)}')
# Body example
body_ref = _find_body_ref(spec, method, path)
if body_ref:
example = _generate_body_example(body_ref, schemas)
parts.append(f" body: '{example}'")
groups.setdefault(tag, []).append('\n'.join(parts) if len(parts) > 1 else parts[0])
for tag, endpoints in sorted(groups.items()):
lines.append(f'[{tag}]')
for ep in endpoints:
lines.append(ep)
lines.append('')
return '\n'.join(lines)
def _static_help(version: str) -> str:
"""Fallback help when OpenAPI spec is unavailable."""
return f"""Browser-Use Cloud API {version}
Usage:
browser-use cloud {version} <METHOD> <path> [body]
browser-use cloud {version} poll <task-id>
Examples:
browser-use cloud {version} GET /browsers
browser-use cloud {version} POST /tasks '{{"task":"Search for AI news","url":"https://google.com"}}'
browser-use cloud {version} GET /tasks/<task-id>
browser-use cloud {version} poll <task-id>
(Could not fetch OpenAPI spec for live endpoint listing)
"""
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def _cloud_login(argv: list[str]) -> int:
if not argv:
print('Usage: browser-use cloud login <api-key>', file=sys.stderr)
return 1
key = argv[0]
_save_api_key(key)
print('API key saved')
return 0
def _cloud_logout() -> int:
if _remove_api_key():
print('API key removed')
else:
print('No API key to remove')
return 0
def _cloud_rest(argv: list[str], version: str) -> int:
"""Generic REST passthrough."""
if len(argv) < 2:
print(f'Usage: browser-use cloud {version} <METHOD> <path> [body]', file=sys.stderr)
return 1
method = argv[0].upper()
path = argv[1]
body_str = argv[2] if len(argv) > 2 else None
# Normalize path
if not path.startswith('/'):
path = '/' + path
url = f'{_base_url(version)}{path}'
api_key = _get_api_key()
body = body_str.encode() if body_str else None
status, resp_body = _http_request(method, url, body, api_key)
if 400 <= status < 500:
print(f'HTTP {status}', file=sys.stderr)
_print_json(resp_body, file=sys.stderr)
# Try to suggest correct body from spec
spec_data = _fetch_spec(version)
if spec_data:
try:
spec = json.loads(spec_data)
example = _find_body_example(spec, method, path)
if example:
print(f"\nExpected body: '{example}'", file=sys.stderr)
except (json.JSONDecodeError, ValueError):
pass
return 2
if status >= 500:
print(f'HTTP {status}', file=sys.stderr)
_print_json(resp_body, file=sys.stderr)
return 1
_print_json(resp_body)
return 0
def _cloud_poll(argv: list[str], version: str) -> int:
"""Poll GET /tasks/<id> until done."""
if not argv:
print(f'Usage: browser-use cloud {version} poll <task-id>', file=sys.stderr)
return 1
task_id = argv[0]
url = f'{_base_url(version)}/tasks/{task_id}'
api_key = _get_api_key()
while True:
status_code, resp_body = _http_request('GET', url, None, api_key)
if status_code >= 400:
print(f'\nHTTP {status_code}', file=sys.stderr)
_print_json(resp_body, file=sys.stderr)
return 2
try:
data = json.loads(resp_body)
except (json.JSONDecodeError, ValueError):
print('\nError: invalid JSON response', file=sys.stderr)
return 1
task_status = data.get('status', 'unknown')
cost = data.get('cost', 0)
print(f'\rstatus: {task_status} cost: ${cost:.4f}', end='', file=sys.stderr, flush=True)
if task_status == 'finished':
print('', file=sys.stderr) # newline
_print_json(resp_body)
return 0
if task_status == 'failed':
print('', file=sys.stderr)
_print_json(resp_body, file=sys.stderr)
return 2
time.sleep(2)
def _cloud_help(version: str) -> int:
"""Show OpenAPI-driven help for a version."""
spec_data = _fetch_spec(version)
if spec_data:
formatted = _format_openapi_help(spec_data)
if formatted:
print(formatted)
return 0
print(_static_help(version))
return 0
def _cloud_versioned(argv: list[str], version: str) -> int:
"""Route versioned subcommands: poll, help, or REST passthrough."""
if not argv:
return _cloud_help(version)
first = argv[0]
if first in ('--help', 'help', '-h'):
return _cloud_help(version)
if first == 'poll':
return _cloud_poll(argv[1:], version)
# REST passthrough: METHOD path [body]
return _cloud_rest(argv, version)
# ---------------------------------------------------------------------------
# Signup (agent self-registration)
# ---------------------------------------------------------------------------
def _signup_challenge() -> int:
"""Request a signup challenge."""
if _get_api_key_or_none():
print('You already have an API key configured.', file=sys.stderr)
print('Run `browser-use cloud signup --claim` to claim your account.', file=sys.stderr)
return 1
body = json.dumps({}).encode()
status, resp = _http_request('POST', f'{_get_base()}/cloud/signup', body, api_key='')
if status >= 400:
print(f'Error: HTTP {status}', file=sys.stderr)
_print_json(resp, file=sys.stderr)
return 1
try:
data = json.loads(resp)
except (json.JSONDecodeError, ValueError):
print('Error: invalid response', file=sys.stderr)
return 1
print(f'Challenge ID: {data["challenge_id"]}')
print(f'Challenge: {data["challenge_text"]}')
print()
print('Verify to create your agent account:')
print(' browser-use cloud signup --verify <challenge-id> <answer>')
return 0
def _signup_verify(challenge_id: str, answer: str) -> int:
"""Verify a signup challenge and save the API key."""
if _get_api_key_or_none():
print('You already have an API key configured.', file=sys.stderr)
print('Run `browser-use cloud signup --claim` to claim your account.', file=sys.stderr)
return 1
body = json.dumps({'challenge_id': challenge_id, 'answer': answer}).encode()
status, resp = _http_request('POST', f'{_get_base()}/cloud/signup/verify', body, api_key='')
if status >= 400:
print(f'Error: HTTP {status}', file=sys.stderr)
_print_json(resp, file=sys.stderr)
return 1
try:
data = json.loads(resp)
except (json.JSONDecodeError, ValueError):
print('Error: invalid response', file=sys.stderr)
return 1
_save_api_key(data['api_key'])
print('API key saved')
return 0
def _signup_claim() -> int:
"""Generate a claim URL for the current API key."""
api_key = _get_api_key()
status, resp = _http_request('POST', f'{_get_base()}/cloud/signup/claim', None, api_key)
if status >= 400:
print(f'Error: HTTP {status}', file=sys.stderr)
_print_json(resp, file=sys.stderr)
return 1
try:
data = json.loads(resp)
except (json.JSONDecodeError, ValueError):
print('Error: invalid response', file=sys.stderr)
return 1
print(f'Claim URL: {data["claim_url"]}')
print('Share this URL with a human to claim ownership of this account.')
return 0
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
def handle_cloud_command(argv: list[str]) -> int:
"""Main dispatcher for `browser-use cloud ...`."""
if not argv:
_print_cloud_usage()
return 1
subcmd = argv[0]
if subcmd == 'login':
return _cloud_login(argv[1:])
if subcmd == 'logout':
return _cloud_logout()
if subcmd in ('v2', 'v3'):
return _cloud_versioned(argv[1:], subcmd)
if subcmd == 'signup':
if '--verify' in argv:
idx = argv.index('--verify')
if idx + 2 >= len(argv):
print('Usage: browser-use cloud signup --verify <challenge-id> <answer>', file=sys.stderr)
return 1
return _signup_verify(argv[idx + 1], argv[idx + 2])
if '--claim' in argv:
return _signup_claim()
return _signup_challenge()
if subcmd == 'connect':
# Normally intercepted by main.py before reaching here
print('Error: cloud connect must be run via the main CLI (browser-use cloud connect)', file=sys.stderr)
return 1
if subcmd in ('--help', 'help', '-h'):
_print_cloud_usage()
return 0
print(f'Unknown cloud subcommand: {subcmd}', file=sys.stderr)
_print_cloud_usage()
return 1
def _print_cloud_usage() -> None:
print('Usage: browser-use cloud <command>')
print()
print('Commands:')
print(' connect Provision cloud browser and connect')
print(' signup Create an agent account (challenge-response)')
print(' signup --verify <id> <answer> Verify challenge and save API key')
print(' signup --claim Generate URL to claim your agent account')
print(' login <api-key> Save API key')
print(' logout Remove API key')
print(' v2 <METHOD> <path> [body] REST passthrough (API v2)')
print(' v3 <METHOD> <path> [body] REST passthrough (API v3)')
print(' v2 poll <task-id> Poll task until done')
print(' v2 --help Show API v2 endpoints')
print(' v3 --help Show API v3 endpoints')
print()
print('Examples:')
print(' browser-use cloud login sk-abc123...')
print(' browser-use cloud v2 GET /browsers')
print(' browser-use cloud v2 POST /tasks \'{"task":"...","url":"https://..."}\'')
print(' browser-use cloud v2 poll <task-id>')