Files
browser-use/browser_use/sync/service.py
2025-07-18 12:49:39 -07:00

199 lines
7.3 KiB
Python

"""
Cloud sync service for sending events to the Browser Use cloud.
"""
import asyncio
import json
import logging
import shutil
import anyio
import httpx
from bubus import BaseEvent
from browser_use.config import CONFIG
from browser_use.sync.auth import TEMP_USER_ID, DeviceAuthClient
logger = logging.getLogger(__name__)
class CloudSync:
"""Service for syncing events to the Browser Use cloud"""
def __init__(self, base_url: str | None = None, enable_auth: bool = True):
# Backend API URL for all API requests - can be passed directly or defaults to env var
self.base_url = base_url or CONFIG.BROWSER_USE_CLOUD_API_URL
self.enable_auth = enable_auth
self.auth_client = DeviceAuthClient(base_url=self.base_url) if enable_auth else None
self.pending_events: list[BaseEvent] = []
self.auth_task = None
self.session_id: str | None = None
async def handle_event(self, event: BaseEvent) -> None:
"""Handle an event by sending it to the cloud"""
try:
# Extract session ID from CreateAgentSessionEvent
if event.event_type == 'CreateAgentSessionEvent' and hasattr(event, 'id'):
self.session_id = str(event.id) # type: ignore
# Start authentication flow on first step (after first LLM response)
if event.event_type == 'CreateAgentStepEvent' and 'step' in dict(event):
step = dict(event)['step']
# logger.debug(f'Got CreateAgentStepEvent with step={step}')
# Trigger on the first step (step=2 because n_steps is incremented before actions)
if step == 2 and self.enable_auth and self.auth_client:
if not hasattr(self, 'auth_task') or self.auth_task is None:
# Start auth in background
if self.session_id:
# logger.info('Triggering auth on first step event')
# Always run auth to show the cloud URL, even if already authenticated
self.auth_task = asyncio.create_task(self._background_auth(agent_session_id=self.session_id))
else:
logger.warning('Cannot start auth - session_id not set yet')
# Send event to cloud
await self._send_event(event)
except Exception as e:
logger.error(f'Failed to handle {event.event_type} event: {type(e).__name__}: {e}', exc_info=True)
async def _send_event(self, event: BaseEvent) -> None:
"""Send event to cloud API"""
try:
headers = {}
# override user_id on event with auth client user_id if available
if self.auth_client:
event.user_id = str(self.auth_client.user_id) # type: ignore
else:
event.user_id = TEMP_USER_ID # type: ignore
# Add auth headers if available
if self.auth_client:
headers.update(self.auth_client.get_headers())
# Send event (batch format with direct BaseEvent serialization)
async with httpx.AsyncClient() as client:
# Serialize event and add device_id to all events
event_data = event.model_dump(mode='json')
if self.auth_client and self.auth_client.device_id:
event_data['device_id'] = self.auth_client.device_id
response = await client.post(
f'{self.base_url.rstrip("/")}/api/v1/events',
json={'events': [event_data]},
headers=headers,
timeout=10.0,
)
if response.status_code == 401 and self.auth_client and not self.auth_client.is_authenticated:
# Store event for retry after auth
self.pending_events.append(event)
elif response.status_code >= 400:
# Log error but don't raise - we want to fail silently
logger.debug(
f'Failed to send sync event: POST {response.request.url} {response.status_code} - {response.text}'
)
except httpx.TimeoutException:
logger.warning(f'⚠️ Event send timed out after 10 seconds: {event}')
except httpx.ConnectError as e:
# logger.warning(f'⚠️ Failed to connect to cloud service at {self.base_url}: {e}')
pass
except httpx.HTTPError as e:
logger.warning(f'⚠️ HTTP error sending event {event}: {type(e).__name__}: {e}')
except Exception as e:
logger.warning(f'⚠️ Unexpected error sending event {event}: {type(e).__name__}: {e}')
async def _background_auth(self, agent_session_id: str) -> None:
"""Run authentication in background or show cloud URL if already authenticated"""
assert self.auth_client, 'enable_auth=True must be set before calling CloudSync_background_auth()'
assert self.session_id, 'session_id must be set before calling CloudSync._background_auth() can fire'
try:
# If already authenticated, just show the cloud URL
if self.auth_client.is_authenticated:
# Use frontend URL for user-facing links
frontend_url = CONFIG.BROWSER_USE_CLOUD_UI_URL or self.base_url.replace('//api.', '//cloud.')
session_url = f'{frontend_url.rstrip("/")}/agent/{agent_session_id}'
terminal_width, _terminal_height = shutil.get_terminal_size((80, 20))
logger.info('' * max(terminal_width - 40, 20) + '\n')
logger.info('🌐 View the details of this run in Browser Use Cloud:')
logger.info(f' 👉 {session_url}')
logger.info('' * max(terminal_width - 40, 20) + '\n\n')
return
# Otherwise run full authentication
success = await self.auth_client.authenticate(
agent_session_id=agent_session_id,
show_instructions=True,
)
if success:
# Resend any pending events
await self._resend_pending_events()
# Update WAL events with real user_id
# await self._update_wal_user_ids(agent_session_id)
except Exception as e:
logger.debug(f'Cloud sync authentication failed: {e}')
async def _resend_pending_events(self) -> None:
"""Resend events that were queued during auth"""
if not self.pending_events:
return
# Send all pending events
for event in self.pending_events:
try:
await self._send_event(event)
except Exception as e:
logger.warning(f'Failed to resend pending event: {e}')
self.pending_events.clear()
# async def _update_wal_user_ids(self, session_id: str) -> None:
# """Update user IDs in WAL file after authentication"""
# try:
# assert self.auth_client, 'Cloud sync must be authenticated to update WAL user ID'
# wal_path = CONFIG.BROWSER_USE_CONFIG_DIR / 'events' / f'{session_id}.jsonl'
# if not await anyio.Path(wal_path).exists():
# raise FileNotFoundError(
# f'CloudSync failed to update saved event user_ids after auth: Agent EventBus WAL file not found: {wal_path}'
# )
# # Read all events
# events = []
# content = await anyio.Path(wal_path).read_text()
# for line in content.splitlines():
# if line.strip():
# events.append(json.loads(line))
# # Update user_id and device_id
# user_id = self.auth_client.user_id
# device_id = self.auth_client.device_id
# for event in events:
# if 'user_id' in event:
# event['user_id'] = user_id
# # Add device_id to all events
# event['device_id'] = device_id
# # Write back
# updated_content = '\n'.join(json.dumps(event) for event in events) + '\n'
# await anyio.Path(wal_path).write_text(updated_content)
# except Exception as e:
# logger.warning(f'Failed to update WAL user IDs: {e}')
async def wait_for_auth(self) -> None:
"""Wait for authentication to complete if in progress"""
if self.auth_task and not self.auth_task.done():
await self.auth_task
async def authenticate(self, show_instructions: bool = True) -> bool:
"""Authenticate with the cloud service"""
if not self.auth_client:
return False
return await self.auth_client.authenticate(agent_session_id=self.session_id, show_instructions=show_instructions)