Files
browser-use/browser_use/skill_cli/browser.py
ShawnPana 0530545c1a fix: API key single source of truth (config.json only), daemon-safe profile creation
- Remove BROWSER_USE_API_KEY env var as a read source from CLI code; config.json is the only source of truth
- Split _create_cloud_profile into daemon-safe _inner (raises) and CLI wrapper (sys.exit)
- Daemon auto-heal no longer kills process on profile creation API errors
2026-04-02 11:51:38 -07:00

226 lines
7.7 KiB
Python

"""Lightweight BrowserSession subclass for the CLI daemon.
Skips watchdogs, event bus handlers, and auto-reconnect for ALL modes.
Launches browser if needed, then calls connect() directly.
All inherited methods (get_element_by_index, take_screenshot, etc.)
work because this IS a BrowserSession.
"""
from __future__ import annotations
import logging
import psutil
from browser_use.browser.session import BrowserSession
logger = logging.getLogger('browser_use.skill_cli.browser')
class CLIBrowserSession(BrowserSession):
"""BrowserSession that skips watchdogs and event bus for all modes.
For --connect: connects to existing Chrome via CDP URL.
For managed Chromium: launches browser, gets CDP URL, connects.
For cloud: provisions browser, gets CDP URL, connects.
All three modes converge at connect() — no watchdogs, no event bus.
"""
_browser_process: psutil.Process | None = None # type: ignore[assignment]
async def start(self) -> None:
"""Launch/provision browser if needed, then connect lightweight."""
if self.cdp_url:
# --connect or --cdp-url: CDP URL already known
pass
elif self.browser_profile.use_cloud:
# Cloud: provision browser via API
await self._provision_cloud_browser()
else:
# Managed Chromium: launch browser process
await self._launch_local_browser()
# All modes: lightweight CDP connection (no watchdogs)
await self.connect()
# Prevent heavy monitoring on future tabs
if self.session_manager:
async def _noop(cdp_session: object) -> None:
pass
self.session_manager._enable_page_monitoring = _noop # type: ignore[assignment]
# Disable auto-reconnect — daemon should die when CDP drops
self._intentional_stop = True
# Register popup/dialog handler so JS alerts don't freeze Chrome
await self._register_dialog_handler()
async def _register_dialog_handler(self) -> None:
"""Register CDP handler to auto-dismiss JS dialogs (alert, confirm, prompt).
Without this, any JS dialog freezes all CDP commands until manually dismissed.
Messages are stored in _closed_popup_messages for inclusion in state output.
"""
import asyncio as _asyncio
if not self._cdp_client_root:
return
async def handle_dialog(event_data: dict, session_id: str | None = None) -> None:
try:
dialog_type = event_data.get('type', 'alert')
message = event_data.get('message', '')
if message:
self._closed_popup_messages.append(f'[{dialog_type}] {message}')
# Accept alerts/confirms/beforeunload, dismiss prompts
should_accept = dialog_type in ('alert', 'confirm', 'beforeunload')
logger.info(f'Auto-{"accepting" if should_accept else "dismissing"} {dialog_type}: {message[:100]}')
if not self._cdp_client_root:
return
await _asyncio.wait_for(
self._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': should_accept},
session_id=session_id,
),
timeout=0.5,
)
except Exception:
pass
# Try to enable Page domain on root client (may fail — not all CDP targets support it)
try:
await self._cdp_client_root.send.Page.enable()
except Exception:
pass
self._cdp_client_root.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
async def _launch_local_browser(self) -> None:
"""Launch Chromium using LocalBrowserWatchdog's launch logic."""
from bubus import EventBus
from browser_use.browser.watchdogs.local_browser_watchdog import LocalBrowserWatchdog
# Instantiate watchdog as plain object — NOT registered on event bus
launcher = LocalBrowserWatchdog(event_bus=EventBus(), browser_session=self)
process, cdp_url = await launcher._launch_browser()
self._browser_process = process
self.browser_profile.cdp_url = cdp_url
logger.info(f'Launched browser (PID {process.pid}), CDP: {cdp_url}')
async def _provision_cloud_browser(self) -> None:
"""Provision a cloud browser and set the CDP URL."""
import os
from browser_use.browser.cloud.views import CreateBrowserRequest
# Override cloud API base URL if set (CLI injects this into daemon env).
# CloudBrowserClient expects the host URL (it appends /api/v2/... internally).
cloud_base = os.environ.get('BROWSER_USE_CLOUD_BASE_URL')
if cloud_base:
self._cloud_browser_client.api_base_url = cloud_base.rstrip('/')
# Ensure CLI has an API key from config.json before proceeding.
from browser_use.skill_cli.config import get_config_value
if not get_config_value('api_key'):
from browser_use.browser.cloud.views import CloudBrowserAuthError
raise CloudBrowserAuthError(
'No API key configured. Run `browser-use cloud login <key>` or `browser-use cloud signup`.'
)
cloud_params = self.browser_profile.cloud_browser_params or CreateBrowserRequest()
# Set recording from CLI config (defaults to True)
from browser_use.skill_cli.config import get_config_value
cloud_params.enable_recording = bool(get_config_value('cloud_connect_recording'))
try:
cloud_response = await self._cloud_browser_client.create_browser(cloud_params)
except Exception as e:
# If profile is invalid, create a new one and retry once
if 'profile' in str(e).lower() or '422' in str(e):
logger.info('Cloud profile invalid, creating new one and retrying')
from browser_use.skill_cli.commands.cloud import _create_cloud_profile_inner
api_key = get_config_value('api_key')
if not api_key:
raise
new_profile_id = _create_cloud_profile_inner(str(api_key))
cloud_params.profile_id = new_profile_id
cloud_response = await self._cloud_browser_client.create_browser(cloud_params)
else:
raise
self.browser_profile.cdp_url = cloud_response.cdpUrl
self.browser_profile.is_local = False
logger.info(f'Cloud browser provisioned, CDP: {cloud_response.cdpUrl}')
async def stop(self) -> None:
"""Disconnect from the browser.
For --connect/--cdp-url: just close the websocket (we don't own the browser).
For cloud: stop the remote browser via API before disconnecting.
"""
self._intentional_stop = True
# Stop cloud browser if we provisioned one
if self.browser_profile.use_cloud and self._cloud_browser_client.current_session_id:
try:
import asyncio as _asyncio
await _asyncio.wait_for(self._cloud_browser_client.stop_browser(), timeout=5.0)
except Exception as e:
logger.debug(f'Error stopping cloud browser: {e}')
if self._cdp_client_root:
try:
await self._cdp_client_root.stop()
except Exception as e:
logger.debug(f'Error closing CDP client: {e}')
self._cdp_client_root = None # type: ignore[assignment]
if self.session_manager:
try:
await self.session_manager.clear()
except Exception as e:
logger.debug(f'Error clearing session manager: {e}')
self.session_manager = None
self.agent_focus_target_id = None
self._cached_selector_map.clear()
async def kill(self) -> None:
"""Send Browser.close to kill the browser, then disconnect.
For managed Chromium: sends Browser.close CDP command + terminates process.
"""
if self._cdp_client_root:
try:
await self._cdp_client_root.send.Browser.close()
except Exception:
pass
await self.stop()
# Force kill the process if we launched it and it's still alive
if self._browser_process:
try:
if self._browser_process.is_running():
self._browser_process.terminate()
self._browser_process.wait(timeout=5)
except Exception:
try:
self._browser_process.kill()
except Exception:
pass
self._browser_process = None
@property
def is_cdp_connected(self) -> bool:
"""Check if CDP WebSocket connection is alive."""
if self._cdp_client_root is None or self._cdp_client_root.ws is None:
return False
try:
from websockets.protocol import State
return self._cdp_client_root.ws.state is State.OPEN
except Exception:
return False