Files
browser-use__browser-use/browser_use/browser/session.py
2026-05-31 13:15:12 -07:00

1352 lines
54 KiB
Python

"""Event-driven browser session with backwards compatibility."""
import asyncio
import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload
from urllib.parse import urlparse, urlunparse
from uuid import UUID
import httpx
from bubus import EventBus
from cdp_use import CDPClient
from cdp_use.cdp.fetch import AuthRequiredEvent, RequestPausedEvent
from cdp_use.cdp.target import SessionID, TargetID
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from uuid_extensions import uuid7str
from browser_use.browser._cdp_timeout import TimeoutWrappedCDPClient
from browser_use.browser.cloud.cloud import CloudBrowserAuthError, CloudBrowserClient, CloudBrowserError
# CDP logging is now handled by setup_logging() in logging_config.py
# It automatically sets CDP logs to the same level as browser_use logs
from browser_use.browser.cloud.views import CloudBrowserParams, CreateBrowserRequest, ProxyCountryCode
# Sentinel to distinguish "not passed" from "explicitly None" for proxy params.
# When a user passes proxy_country_code=None, they mean "disable the proxy".
# When they don't pass it at all, the server applies its default (US proxy).
_UNSET: Any = object()
from browser_use.browser.events import (
AgentFocusChangedEvent,
BrowserConnectedEvent,
BrowserErrorEvent,
BrowserLaunchResult,
BrowserReconnectedEvent,
BrowserReconnectingEvent,
BrowserStartEvent,
)
from browser_use.browser.profile import BrowserProfile, ProxySettings
from browser_use.browser.session_actor_api import BrowserSessionActorAPIMixin
from browser_use.browser.session_cdp import BrowserSessionCDPMixin
from browser_use.browser.session_dom import BrowserSessionDOMMixin
from browser_use.browser.session_frames import BrowserSessionFramesMixin
from browser_use.browser.session_highlights import BrowserSessionHighlightMixin
from browser_use.browser.session_lifecycle import BrowserSessionLifecycleMixin
from browser_use.browser.session_navigation import BrowserSessionNavigationMixin
from browser_use.browser.session_screenshots import BrowserSessionScreenshotMixin
from browser_use.browser.session_state import BrowserSessionStateMixin
from browser_use.browser.session_tab_events import BrowserSessionTabEventsMixin
from browser_use.browser.views import BrowserStateSummary
from browser_use.dom.views import EnhancedDOMTreeNode
from browser_use.observability import observe_debug
from browser_use.utils import create_task_with_error_handling
if TYPE_CHECKING:
from browser_use.browser.demo_mode import DemoMode
DEFAULT_BROWSER_PROFILE = BrowserProfile()
class Target(BaseModel):
"""Browser target (page, iframe, worker) - the actual entity being controlled.
A target represents a browsing context with its own URL, title, and type.
Multiple CDP sessions can attach to the same target for communication.
"""
model_config = ConfigDict(arbitrary_types_allowed=True, revalidate_instances='never')
target_id: TargetID
target_type: str # 'page', 'iframe', 'worker', etc.
url: str = 'about:blank'
title: str = 'Unknown title'
class CDPSession(BaseModel):
"""CDP communication channel to a target.
A session is a connection that allows sending CDP commands to a specific target.
Multiple sessions can attach to the same target.
"""
model_config = ConfigDict(arbitrary_types_allowed=True, revalidate_instances='never')
cdp_client: CDPClient
target_id: TargetID
session_id: SessionID
# Lifecycle monitoring (populated by SessionManager)
_lifecycle_events: Any = PrivateAttr(default=None)
_lifecycle_lock: Any = PrivateAttr(default=None)
class BrowserSession(
BrowserSessionActorAPIMixin,
BrowserSessionCDPMixin,
BrowserSessionDOMMixin,
BrowserSessionFramesMixin,
BrowserSessionHighlightMixin,
BrowserSessionLifecycleMixin,
BrowserSessionNavigationMixin,
BrowserSessionScreenshotMixin,
BrowserSessionTabEventsMixin,
BrowserSessionStateMixin,
BaseModel,
):
"""Event-driven browser session with backwards compatibility.
This class provides a 2-layer architecture:
- High-level event handling for agents/tools
- Direct CDP/Playwright calls for browser operations
Supports both event-driven and imperative calling styles.
Browser configuration is stored in the browser_profile, session identity in direct fields:
```python
# Direct settings (recommended for most users)
session = BrowserSession(headless=True, user_data_dir='./profile')
# Or use a profile (for advanced use cases)
session = BrowserSession(browser_profile=BrowserProfile(...))
# Access session fields directly, browser settings via profile or property
print(session.id) # Session field
```
"""
model_config = ConfigDict(
arbitrary_types_allowed=True,
validate_assignment=True,
extra='forbid',
revalidate_instances='never', # resets private attrs on every model rebuild
)
# Overload 1: Cloud browser mode (use cloud-specific params)
@overload
def __init__(
self,
*,
# Cloud browser params - use these for cloud mode
cloud_profile_id: UUID | str | None = None,
cloud_proxy_country_code: ProxyCountryCode | None = None,
cloud_timeout: int | None = None,
# Backward compatibility aliases
profile_id: UUID | str | None = None,
proxy_country_code: ProxyCountryCode | None = None,
timeout: int | None = None,
use_cloud: bool | None = None,
cloud_browser: bool | None = None, # Backward compatibility alias
cloud_browser_params: CloudBrowserParams | None = None,
# Common params that work with cloud
id: str | None = None,
headers: dict[str, str] | None = None,
allowed_domains: list[str] | None = None,
prohibited_domains: list[str] | None = None,
keep_alive: bool | None = None,
minimum_wait_page_load_time: float | None = None,
wait_for_network_idle_page_load_time: float | None = None,
wait_between_actions: float | None = None,
captcha_solver: bool | None = None,
auto_download_pdfs: bool | None = None,
cookie_whitelist_domains: list[str] | None = None,
cross_origin_iframes: bool | None = None,
highlight_elements: bool | None = None,
dom_highlight_elements: bool | None = None,
paint_order_filtering: bool | None = None,
max_iframes: int | None = None,
max_iframe_depth: int | None = None,
) -> None: ...
# Overload 2: Local browser mode (use local browser params)
@overload
def __init__(
self,
*,
# Core configuration for local
id: str | None = None,
cdp_url: str | None = None,
browser_profile: BrowserProfile | None = None,
# Local browser launch params
executable_path: str | Path | None = None,
headless: bool | None = None,
user_data_dir: str | Path | None = None,
args: list[str] | None = None,
downloads_path: str | Path | None = None,
# Common params
headers: dict[str, str] | None = None,
allowed_domains: list[str] | None = None,
prohibited_domains: list[str] | None = None,
keep_alive: bool | None = None,
minimum_wait_page_load_time: float | None = None,
wait_for_network_idle_page_load_time: float | None = None,
wait_between_actions: float | None = None,
auto_download_pdfs: bool | None = None,
cookie_whitelist_domains: list[str] | None = None,
cross_origin_iframes: bool | None = None,
highlight_elements: bool | None = None,
dom_highlight_elements: bool | None = None,
paint_order_filtering: bool | None = None,
max_iframes: int | None = None,
max_iframe_depth: int | None = None,
# All other local params
env: dict[str, str | float | bool] | None = None,
ignore_default_args: list[str] | Literal[True] | None = None,
channel: str | None = None,
chromium_sandbox: bool | None = None,
devtools: bool | None = None,
traces_dir: str | Path | None = None,
accept_downloads: bool | None = None,
permissions: list[str] | None = None,
user_agent: str | None = None,
screen: dict | None = None,
viewport: dict | None = None,
no_viewport: bool | None = None,
device_scale_factor: float | None = None,
record_har_content: str | None = None,
record_har_mode: str | None = None,
record_har_path: str | Path | None = None,
record_video_dir: str | Path | None = None,
record_video_framerate: int | None = None,
record_video_size: dict | None = None,
storage_state: str | Path | dict[str, Any] | None = None,
disable_security: bool | None = None,
deterministic_rendering: bool | None = None,
proxy: ProxySettings | None = None,
enable_default_extensions: bool | None = None,
captcha_solver: bool | None = None,
window_size: dict | None = None,
window_position: dict | None = None,
filter_highlight_ids: bool | None = None,
profile_directory: str | None = None,
) -> None: ...
def __init__(
self,
# Core configuration
id: str | None = None,
cdp_url: str | None = None,
is_local: bool = False,
browser_profile: BrowserProfile | None = None,
# Cloud browser params (don't mix with local browser params)
cloud_profile_id: UUID | str | None = None,
cloud_proxy_country_code: ProxyCountryCode | None = _UNSET, # type: ignore[assignment]
cloud_timeout: int | None = None,
# Backward compatibility aliases for cloud params
profile_id: UUID | str | None = None,
proxy_country_code: ProxyCountryCode | None = _UNSET, # type: ignore[assignment]
timeout: int | None = None,
# BrowserProfile fields that can be passed directly
# From BrowserConnectArgs
headers: dict[str, str] | None = None,
# From BrowserLaunchArgs
env: dict[str, str | float | bool] | None = None,
executable_path: str | Path | None = None,
headless: bool | None = None,
args: list[str] | None = None,
ignore_default_args: list[str] | Literal[True] | None = None,
channel: str | None = None,
chromium_sandbox: bool | None = None,
devtools: bool | None = None,
downloads_path: str | Path | None = None,
traces_dir: str | Path | None = None,
# From BrowserContextArgs
accept_downloads: bool | None = None,
permissions: list[str] | None = None,
user_agent: str | None = None,
screen: dict | None = None,
viewport: dict | None = None,
no_viewport: bool | None = None,
device_scale_factor: float | None = None,
record_har_content: str | None = None,
record_har_mode: str | None = None,
record_har_path: str | Path | None = None,
record_video_dir: str | Path | None = None,
record_video_framerate: int | None = None,
record_video_size: dict | None = None,
# From BrowserLaunchPersistentContextArgs
user_data_dir: str | Path | None = None,
# From BrowserNewContextArgs
storage_state: str | Path | dict[str, Any] | None = None,
# BrowserProfile specific fields
## Cloud Browser Fields
use_cloud: bool | None = None,
cloud_browser: bool | None = None, # Backward compatibility alias
cloud_browser_params: CloudBrowserParams | None = None,
## Other params
disable_security: bool | None = None,
deterministic_rendering: bool | None = None,
allowed_domains: list[str] | None = None,
prohibited_domains: list[str] | None = None,
keep_alive: bool | None = None,
proxy: ProxySettings | None = None,
enable_default_extensions: bool | None = None,
captcha_solver: bool | None = None,
window_size: dict | None = None,
window_position: dict | None = None,
minimum_wait_page_load_time: float | None = None,
wait_for_network_idle_page_load_time: float | None = None,
wait_between_actions: float | None = None,
filter_highlight_ids: bool | None = None,
auto_download_pdfs: bool | None = None,
profile_directory: str | None = None,
cookie_whitelist_domains: list[str] | None = None,
# DOM extraction layer configuration
cross_origin_iframes: bool | None = None,
highlight_elements: bool | None = None,
dom_highlight_elements: bool | None = None,
paint_order_filtering: bool | None = None,
# Iframe processing limits
max_iframes: int | None = None,
max_iframe_depth: int | None = None,
):
# Following the same pattern as AgentSettings in service.py
# Only pass non-None values to avoid validation errors
# Also filter _UNSET sentinel values (used for proxy params)
profile_kwargs = {
k: v
for k, v in locals().items()
if k
not in [
'self',
'browser_profile',
'id',
'cloud_profile_id',
'cloud_proxy_country_code',
'cloud_timeout',
'profile_id',
'proxy_country_code',
'timeout',
]
and v is not None
and v is not _UNSET
}
# Handle backward compatibility: prefer cloud_* params over old names.
# _UNSET means "not passed" while None means "explicitly disable proxy".
final_profile_id = cloud_profile_id if cloud_profile_id is not None else profile_id
final_proxy_country_code = (
cloud_proxy_country_code
if cloud_proxy_country_code is not _UNSET
else proxy_country_code
if proxy_country_code is not _UNSET
else _UNSET
)
final_timeout = cloud_timeout if cloud_timeout is not None else timeout
# If any cloud params are provided, create cloud_browser_params.
# Use "is not _UNSET" for proxy so that explicit None (disable proxy) is respected.
if final_profile_id is not None or final_proxy_country_code is not _UNSET or final_timeout is not None:
cloud_kwargs: dict[str, Any] = {}
if final_profile_id is not None:
cloud_kwargs['cloud_profile_id'] = final_profile_id
if final_proxy_country_code is not _UNSET:
cloud_kwargs['cloud_proxy_country_code'] = final_proxy_country_code
if final_timeout is not None:
cloud_kwargs['cloud_timeout'] = final_timeout
cloud_params = CreateBrowserRequest(**cloud_kwargs)
profile_kwargs['cloud_browser_params'] = cloud_params
profile_kwargs['use_cloud'] = True
# Handle backward compatibility: map cloud_browser to use_cloud
if 'cloud_browser' in profile_kwargs:
profile_kwargs['use_cloud'] = profile_kwargs.pop('cloud_browser')
# If cloud_browser_params is set, force use_cloud=True
if cloud_browser_params is not None:
profile_kwargs['use_cloud'] = True
# if is_local is False but executable_path is provided, set is_local to True
if is_local is False and executable_path is not None:
profile_kwargs['is_local'] = True
# Only set is_local=True when cdp_url is missing if we're not using cloud browser
# (cloud browser will provide cdp_url later)
use_cloud = profile_kwargs.get('use_cloud') or profile_kwargs.get('cloud_browser')
if not cdp_url and not use_cloud:
profile_kwargs['is_local'] = True
# Create browser profile from direct parameters or use provided one
if browser_profile is not None:
# Merge any direct kwargs into the provided browser_profile (direct kwargs take precedence)
merged_kwargs = {**browser_profile.model_dump(exclude_unset=True), **profile_kwargs}
resolved_browser_profile = BrowserProfile(**merged_kwargs)
else:
resolved_browser_profile = BrowserProfile(**profile_kwargs)
# Initialize the Pydantic model
super().__init__(
id=id or str(uuid7str()),
browser_profile=resolved_browser_profile,
)
# Session configuration (session identity only)
id: str = Field(default_factory=lambda: str(uuid7str()), description='Unique identifier for this browser session')
# Browser configuration (reusable profile)
browser_profile: BrowserProfile = Field(
default_factory=lambda: DEFAULT_BROWSER_PROFILE,
description='BrowserProfile() options to use for the session, otherwise a default profile will be used',
)
# LLM screenshot resizing configuration
llm_screenshot_size: tuple[int, int] | None = Field(
default=None,
description='Target size (width, height) to resize screenshots before sending to LLM. Coordinates from LLM will be scaled back to original viewport size.',
)
# Cache of original viewport size for coordinate conversion (set when browser state is captured)
_original_viewport_size: tuple[int, int] | None = PrivateAttr(default=None)
@classmethod
def from_system_chrome(cls, profile_directory: str | None = None, **kwargs: Any) -> Self:
"""Create a BrowserSession using system's Chrome installation and profile"""
from browser_use.skill_cli.utils import find_chrome_executable, get_chrome_profile_path, list_chrome_profiles
executable_path = find_chrome_executable()
if executable_path is None:
raise RuntimeError(
'Chrome not found. Please install Chrome or use Browser() with explicit executable_path.\n'
'Expected locations:\n'
' macOS: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome\n'
' Linux: /usr/bin/google-chrome or /usr/bin/chromium\n'
' Windows: C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
)
user_data_dir = get_chrome_profile_path(None)
if user_data_dir is None:
raise RuntimeError(
'Could not detect Chrome profile directory for your platform.\n'
'Expected locations:\n'
' macOS: ~/Library/Application Support/Google/Chrome\n'
' Linux: ~/.config/google-chrome or ~/.config/chromium\n'
' Windows: %LocalAppData%\\Google\\Chrome\\User Data'
)
# Auto-select profile if not specified
profiles = list_chrome_profiles()
if profile_directory is None:
if profiles:
# Use first available profile
profile_directory = profiles[0]['directory']
logging.getLogger('browser_use').info(
f'Auto-selected Chrome profile: {profiles[0]["name"]} ({profile_directory})'
)
else:
profile_directory = 'Default'
return cls(
executable_path=executable_path,
user_data_dir=user_data_dir,
profile_directory=profile_directory,
**kwargs,
)
@classmethod
def list_chrome_profiles(cls) -> list[dict[str, str]]:
"""List available Chrome profiles on the system"""
from browser_use.skill_cli.utils import list_chrome_profiles
return list_chrome_profiles()
# Main shared event bus for all browser session + all watchdogs
event_bus: EventBus = Field(default_factory=EventBus)
# Mutable public state - which target has agent focus
agent_focus_target_id: TargetID | None = None
# Mutable private state shared between watchdogs
_cdp_client_root: CDPClient | None = PrivateAttr(default=None)
_connection_lock: Any = PrivateAttr(default=None) # asyncio.Lock for preventing concurrent connections
# PUBLIC: SessionManager instance (OWNS all targets and sessions)
session_manager: Any = Field(default=None, exclude=True) # SessionManager
_cached_browser_state_summary: Any = PrivateAttr(default=None)
_cached_selector_map: dict[int, EnhancedDOMTreeNode] = PrivateAttr(default_factory=dict)
_downloaded_files: list[str] = PrivateAttr(default_factory=list) # Track files downloaded during this session
_closed_popup_messages: list[str] = PrivateAttr(default_factory=list) # Store messages from auto-closed JavaScript dialogs
_dialog_listeners_registered: set[str] = PrivateAttr(default_factory=set)
# Watchdogs
_crash_watchdog: Any | None = PrivateAttr(default=None)
_downloads_watchdog: Any | None = PrivateAttr(default=None)
_aboutblank_watchdog: Any | None = PrivateAttr(default=None)
_security_watchdog: Any | None = PrivateAttr(default=None)
_storage_state_watchdog: Any | None = PrivateAttr(default=None)
_local_browser_watchdog: Any | None = PrivateAttr(default=None)
_default_action_watchdog: Any | None = PrivateAttr(default=None)
_dom_watchdog: Any | None = PrivateAttr(default=None)
_screenshot_watchdog: Any | None = PrivateAttr(default=None)
_permissions_watchdog: Any | None = PrivateAttr(default=None)
_recording_watchdog: Any | None = PrivateAttr(default=None)
_har_recording_watchdog: Any | None = PrivateAttr(default=None)
_captcha_watchdog: Any | None = PrivateAttr(default=None)
_watchdogs_attached: bool = PrivateAttr(default=False)
_cloud_browser_client: CloudBrowserClient = PrivateAttr(default_factory=lambda: CloudBrowserClient())
_demo_mode: 'DemoMode | None' = PrivateAttr(default=None)
# WebSocket reconnection state
# Max wait = attempts * timeout_per_attempt + sum(delays) + small buffer
# Default: 3 * 15s + (1+2+4)s + 2s = 54s
RECONNECT_WAIT_TIMEOUT: float = 54.0
_reconnecting: bool = PrivateAttr(default=False)
_reconnect_event: asyncio.Event = PrivateAttr(default_factory=asyncio.Event)
_reconnect_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
_reconnect_task: asyncio.Task | None = PrivateAttr(default=None)
_intentional_stop: bool = PrivateAttr(default=False)
_logger: Any = PrivateAttr(default=None)
async def on_BrowserStartEvent(self, event: BrowserStartEvent) -> dict[str, str]:
"""Compatibility adapter for browser start events."""
return await self.start_direct()
@observe_debug(ignore_input=True, ignore_output=True, name='browser_start_direct')
async def start_direct(self) -> dict[str, str]:
"""Start/connect to the browser.
Returns:
Dict with 'cdp_url' key containing the CDP URL
Note: This method is idempotent - calling start() multiple times is safe.
- If already connected, it skips reconnection
- If you need to reset state, call stop() or kill() first
"""
# Initialize and attach all watchdogs FIRST so the local browser launcher is available.
await self.attach_all_watchdogs()
try:
# If no CDP URL, launch local browser or cloud browser
if not self.cdp_url:
if self.browser_profile.use_cloud or self.browser_profile.cloud_browser_params is not None:
# Use cloud browser service
try:
# Use cloud_browser_params if provided, otherwise create empty request
cloud_params = self.browser_profile.cloud_browser_params or CreateBrowserRequest()
cloud_browser_response = await self._cloud_browser_client.create_browser(cloud_params)
self.browser_profile.cdp_url = cloud_browser_response.cdpUrl
self.browser_profile.is_local = False
self.logger.info('🌤️ Successfully connected to cloud browser service')
except CloudBrowserAuthError:
raise
except CloudBrowserError as e:
raise CloudBrowserError(f'Failed to create cloud browser: {e}')
elif self.is_local:
local_browser_watchdog = self._local_browser_watchdog
if local_browser_watchdog is None:
raise RuntimeError('Local browser launch service is not attached to this browser session.')
launch_result: BrowserLaunchResult = await local_browser_watchdog.launch_browser()
self.browser_profile.cdp_url = launch_result.cdp_url
else:
raise ValueError('Got BrowserSession(is_local=False) but no cdp_url was provided to connect to!')
assert self.cdp_url and '://' in self.cdp_url
# Use lock to prevent concurrent connection attempts (race condition protection)
async with self._connection_lock:
# Only connect if not already connected
if self._cdp_client_root is None:
# Setup browser via CDP (for both local and remote cases)
# Global timeout prevents connect() from hanging indefinitely on
# slow/broken WebSocket connections (common on Lambda → remote browser)
try:
await asyncio.wait_for(self.connect(cdp_url=self.cdp_url), timeout=15.0)
except TimeoutError:
# Timeout cancels connect() via CancelledError, which bypasses
# connect()'s `except Exception` cleanup (CancelledError is BaseException).
# Clean up the partially-initialized client so future start attempts
# don't skip reconnection due to _cdp_client_root being non-None.
cdp_client = cast(CDPClient | None, self._cdp_client_root)
if cdp_client is not None:
try:
await cdp_client.stop()
except Exception:
pass
self._cdp_client_root = None
manager = self.session_manager
if manager is not None:
try:
await manager.clear()
except Exception:
pass
self.session_manager = None
self.agent_focus_target_id = None
raise RuntimeError(
f'connect() timed out after 15s — CDP connection to {self.cdp_url} is too slow or unresponsive'
)
assert self.cdp_client is not None
# Browser-connected services are part of direct startup now. The event is
# retained as a compatibility notification for external listeners.
await self._initialize_browser_connected_services_direct()
try:
await self.event_bus.dispatch(BrowserConnectedEvent(cdp_url=self.cdp_url))
except Exception as exc:
self.logger.debug(f'BrowserConnectedEvent compatibility notification failed: {type(exc).__name__}: {exc}')
if self.browser_profile.demo_mode:
try:
demo = self.demo_mode
if demo:
await demo.ensure_ready()
except Exception as exc:
self.logger.warning(f'[DemoMode] Failed to inject demo overlay: {exc}')
else:
self.logger.debug('Already connected to CDP, skipping reconnection')
if self.browser_profile.demo_mode:
try:
demo = self.demo_mode
if demo:
await demo.ensure_ready()
except Exception as exc:
self.logger.warning(f'[DemoMode] Failed to inject demo overlay: {exc}')
# Return the CDP URL for other components
return {'cdp_url': self.cdp_url}
except Exception as e:
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='BrowserStartEventError',
message=f'Failed to start browser: {type(e).__name__} {e}',
details={'cdp_url': self.cdp_url, 'is_local': self.is_local},
)
)
if self.is_local and not isinstance(e, (CloudBrowserAuthError, CloudBrowserError)):
self.logger.warning(
'Local browser failed to start. Cloud browsers require no local install and work out of the box.\n'
' Try: Browser(use_cloud=True) | Get an API key: https://cloud.browser-use.com?utm_source=oss&utm_medium=browser_launch_failure'
)
raise
async def get_or_create_cdp_session(self, target_id: TargetID | None = None, focus: bool = True) -> CDPSession:
"""Get CDP session for a target from the event-driven pool.
With autoAttach=True, sessions are created automatically by Chrome and added
to the pool via Target.attachedToTarget events. This method retrieves them.
Args:
target_id: Target ID to get session for. If None, uses current agent focus.
focus: If True, switches agent focus to this target (page targets only).
Returns:
CDPSession for the specified target.
Raises:
ValueError: If target doesn't exist or session is not available.
"""
assert self._cdp_client_root is not None, 'Root CDP client not initialized'
assert self.session_manager is not None, 'SessionManager not initialized'
# If no target_id specified, ensure current agent focus is valid and wait for recovery if needed
if target_id is None:
# Validate and wait for focus recovery if stale (centralized protection)
focus_valid = await self.session_manager.ensure_valid_focus(timeout=5.0)
if not focus_valid:
raise ValueError(
'No valid agent focus available - target may have detached and recovery failed. '
'This indicates browser is in an unstable state.'
)
assert self.agent_focus_target_id is not None, 'Focus validation passed but agent_focus_target_id is None'
target_id = self.agent_focus_target_id
session = self.session_manager._get_session_for_target(target_id)
if not session:
# Session not in pool yet - wait for attach event
self.logger.debug(f'[SessionManager] Waiting for target {target_id[:8]}... to attach...')
# Wait up to 2 seconds for the attach event
for attempt in range(20):
await asyncio.sleep(0.1)
session = self.session_manager._get_session_for_target(target_id)
if session:
self.logger.debug(f'[SessionManager] Target appeared after {attempt * 100}ms')
break
if not session:
# Timeout - target doesn't exist
raise ValueError(f'Target {target_id} not found - may have detached or never existed')
# Validate session is still active
is_valid = await self.session_manager.validate_session(target_id)
if not is_valid:
raise ValueError(f'Target {target_id} has detached - no active sessions')
# Update focus if requested
# CRITICAL: Only allow focus change to 'page' type targets, not iframes/workers
if focus and self.agent_focus_target_id != target_id:
# Get target type from SessionManager
target = self.session_manager.get_target(target_id)
target_type = target.target_type if target else 'unknown'
if target_type == 'page':
# Format current focus safely (could be None after detach)
current_focus = self.agent_focus_target_id[:8] if self.agent_focus_target_id else 'None'
self.logger.debug(f'[SessionManager] Switching focus: {current_focus}... → {target_id[:8]}...')
self.agent_focus_target_id = target_id
else:
# Ignore focus request for non-page targets (iframes, workers, etc.)
# These can detach at any time, causing agent_focus to point to dead target
current_focus = self.agent_focus_target_id[:8] if self.agent_focus_target_id else 'None'
self.logger.debug(
f'[SessionManager] Ignoring focus request for {target_type} target {target_id[:8]}... '
f'(agent_focus stays on {current_focus}...)'
)
# Resume if waiting for debugger (non-essential, don't let it block connect)
if focus:
try:
await asyncio.wait_for(
session.cdp_client.send.Runtime.runIfWaitingForDebugger(session_id=session.session_id),
timeout=3.0,
)
except Exception:
pass # May fail if not waiting, or timeout — either is fine
return session
async def set_extra_headers(self, headers: dict[str, str], target_id: TargetID | None = None) -> None:
"""Set extra HTTP headers using CDP Network.setExtraHTTPHeaders.
These headers will be sent with every HTTP request made by the target.
Network domain must be enabled first (done automatically for page targets
in SessionManager._enable_page_monitoring).
Args:
headers: Dictionary of header name -> value pairs to inject into every request.
target_id: Target to set headers on. Defaults to the current agent focus target.
"""
if target_id is None:
if not self.agent_focus_target_id:
return
target_id = self.agent_focus_target_id
cdp_session = await self.get_or_create_cdp_session(target_id, focus=False)
# Ensure Network domain is enabled (idempotent - safe to call multiple times)
await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
await cdp_session.cdp_client.send.Network.setExtraHTTPHeaders(
params={'headers': cast(Any, headers)}, session_id=cdp_session.session_id
)
# endregion - ========== CDP-based ... ==========
# region - ========== Helper Methods ==========
@observe_debug(ignore_input=True, ignore_output=True, name='get_browser_state_summary')
async def get_browser_state_summary(
self,
include_screenshot: bool = True,
cached: bool = False,
include_recent_events: bool = False,
) -> BrowserStateSummary:
if cached and self._cached_browser_state_summary is not None and self._cached_browser_state_summary.dom_state:
# Don't use cached state if it has 0 interactive elements
selector_map = self._cached_browser_state_summary.dom_state.selector_map
# Don't use cached state if we need a screenshot but the cached state doesn't have one
if include_screenshot and not self._cached_browser_state_summary.screenshot:
self.logger.debug('⚠️ Cached browser state has no screenshot, fetching fresh state with screenshot')
# Fall through to fetch fresh state with screenshot
elif selector_map and len(selector_map) > 0:
self.logger.debug('🔄 Using pre-cached browser state summary for open tab')
return self._cached_browser_state_summary
else:
self.logger.debug('⚠️ Cached browser state has 0 interactive elements, fetching fresh state')
# Fall through to fetch fresh state
dom_watchdog = self._dom_watchdog
if dom_watchdog is None:
await self.attach_all_watchdogs()
dom_watchdog = self._dom_watchdog
if dom_watchdog is None:
raise RuntimeError('DOM state service is not attached to this browser session.')
result = await dom_watchdog.get_browser_state_summary(
include_dom=True,
include_screenshot=include_screenshot,
include_recent_events=include_recent_events,
)
assert result is not None and result.dom_state is not None
return result
async def get_state_as_text(self) -> str:
"""Get the browser state as text."""
state = await self.get_browser_state_summary()
assert state.dom_state is not None
dom_state = state.dom_state
return dom_state.llm_representation()
async def attach_all_watchdogs(self) -> None:
"""Initialize and attach all watchdogs with explicit handler registration."""
from browser_use.browser.watchdogs.attachment import attach_all_watchdogs
await attach_all_watchdogs(self)
async def connect(self, cdp_url: str | None = None) -> Self:
"""Connect to a remote chromium-based browser via CDP using cdp-use.
This MUST succeed or the browser is unusable. Fails hard on any error.
"""
self.browser_profile.cdp_url = cdp_url or self.cdp_url
if not self.cdp_url:
raise RuntimeError('Cannot setup CDP connection without CDP URL')
# Prevent duplicate connections - clean up existing connection first
if self._cdp_client_root is not None:
self.logger.warning(
'⚠️ connect() called but CDP client already exists! Cleaning up old connection before creating new one.'
)
try:
await self._cdp_client_root.stop()
except Exception as e:
self.logger.debug(f'Error stopping old CDP client: {e}')
self._cdp_client_root = None
if not self.cdp_url.startswith('ws'):
# If it's an HTTP URL, fetch the WebSocket URL from /json/version endpoint
parsed_url = urlparse(self.cdp_url)
path = parsed_url.path.rstrip('/')
if not path.endswith('/json/version'):
path = path + '/json/version'
url = urlunparse(
(parsed_url.scheme, parsed_url.netloc, path, parsed_url.params, parsed_url.query, parsed_url.fragment)
)
# Run a tiny HTTP client to query for the WebSocket URL from the /json/version endpoint
# Default httpx timeout is 5s which can race the global wait_for(connect(), 15s).
# Use 30s as a safety net for direct connect() callers; the wait_for is the real deadline.
# For localhost/127.0.0.1, disable trust_env to prevent proxy env vars (HTTP_PROXY, HTTPS_PROXY)
# from routing local requests through a proxy, which causes 502 errors on Windows.
# Remote CDP URLs should still respect proxy settings.
is_localhost = parsed_url.hostname in ('localhost', '127.0.0.1', '::1')
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=not is_localhost) as client:
headers = dict(self.browser_profile.headers or {})
from browser_use.utils import get_browser_use_version
headers.setdefault('User-Agent', f'browser-use/{get_browser_use_version()}')
version_info = await client.get(url, headers=headers)
self.logger.debug(f'Raw version info: {str(version_info)}')
self.browser_profile.cdp_url = version_info.json()['webSocketDebuggerUrl']
assert self.cdp_url is not None, 'CDP URL is None.'
browser_location = 'local browser' if self.is_local else 'remote browser'
self.logger.debug(f'🌎 Connecting to existing chromium-based browser via CDP: {self.cdp_url} -> ({browser_location})')
try:
# Create and store the CDP client for direct CDP communication
headers = dict(getattr(self.browser_profile, 'headers', None) or {})
if not self.is_local:
from browser_use.utils import get_browser_use_version
headers.setdefault('User-Agent', f'browser-use/{get_browser_use_version()}')
self._cdp_client_root = TimeoutWrappedCDPClient(
self.cdp_url,
additional_headers=headers or None,
max_ws_frame_size=200 * 1024 * 1024, # Use 200MB limit to handle pages with very large DOMs
)
assert self._cdp_client_root is not None
await self._cdp_client_root.start()
# Initialize event-driven session manager FIRST (before enabling autoAttach)
# SessionManager will:
# 1. Register attach/detach event handlers
# 2. Discover and attach to all existing targets
# 3. Initialize sessions and enable lifecycle monitoring
# 4. Enable autoAttach for future targets
from browser_use.browser.session_manager import SessionManager
self.session_manager = SessionManager(self)
await self.session_manager.start_monitoring()
self.logger.debug('Event-driven session manager started')
# Enable auto-attach so Chrome automatically notifies us when NEW targets attach/detach
# This is the foundation of event-driven session management
await self._cdp_client_root.send.Target.setAutoAttach(
params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}
)
self.logger.debug('CDP client connected with auto-attach enabled')
# Get browser targets from SessionManager (source of truth)
# SessionManager has already discovered all targets via start_monitoring()
page_targets_from_manager = self.session_manager.get_all_page_targets()
# Check for chrome://newtab pages and redirect them to about:blank (in parallel)
from browser_use.utils import is_new_tab_page
async def _redirect_newtab(target):
target_url = target.url
target_id = target.target_id
self.logger.debug(f'🔄 Redirecting {target_url} to about:blank for target {target_id}')
try:
session = await self.get_or_create_cdp_session(target_id, focus=False)
await session.cdp_client.send.Page.navigate(params={'url': 'about:blank'}, session_id=session.session_id)
target.url = 'about:blank'
except Exception as e:
self.logger.warning(f'Failed to redirect {target_url}: {e}')
redirect_tasks = [
_redirect_newtab(target)
for target in page_targets_from_manager
if is_new_tab_page(target.url) and target.url != 'about:blank'
]
if redirect_tasks:
await asyncio.gather(*redirect_tasks, return_exceptions=True)
# Ensure we have at least one page
if not page_targets_from_manager:
new_target = await self._cdp_client_root.send.Target.createTarget(params={'url': 'about:blank'})
target_id = new_target['targetId']
self.logger.debug(f'📄 Created new blank page: {target_id}')
else:
target_id = page_targets_from_manager[0].target_id
self.logger.debug(f'📄 Using existing page: {target_id}')
# Set up initial focus using the public API
# Note: get_or_create_cdp_session() will wait for attach event and set focus
try:
await self.get_or_create_cdp_session(target_id, focus=True)
# agent_focus_target_id is now set by get_or_create_cdp_session
self.logger.debug(f'📄 Agent focus set to {target_id[:8]}...')
except ValueError as e:
raise RuntimeError(f'Failed to get session for initial target {target_id}: {e}') from e
# Note: Lifecycle monitoring is enabled automatically in SessionManager._handle_target_attached()
# when targets attach, so no manual enablement needed!
# Enable proxy authentication handling if configured
await self._setup_proxy_auth()
# Attach WS drop detection callback for auto-reconnection
self._intentional_stop = False
self._attach_ws_drop_callback()
# Verify the target is working
if self.agent_focus_target_id:
target = self.session_manager.get_target(self.agent_focus_target_id)
if target.title == 'Unknown title':
self.logger.warning('Target created but title is unknown (may be normal for about:blank)')
# Initialize per-target services directly. The event remains a compatibility notification.
if page_targets_from_manager:
for idx, target in enumerate(page_targets_from_manager):
target_url = target.url
self.logger.debug(f'Initializing target services for initial tab {idx}: {target_url}')
await self._initialize_target_services_direct(target.target_id, target_url)
await self._notify_tab_created_compatibility(target.target_id, target_url)
else:
self.logger.debug('Initializing target services for newly-created initial about:blank tab')
await self._initialize_target_services_direct(target_id, 'about:blank')
await self._notify_tab_created_compatibility(target_id, 'about:blank')
# Dispatch initial focus event
if page_targets_from_manager:
initial_url = page_targets_from_manager[0].url
self.event_bus.dispatch(AgentFocusChangedEvent(target_id=page_targets_from_manager[0].target_id, url=initial_url))
self.logger.debug(f'Initial agent focus set to tab 0: {initial_url}')
except Exception as e:
# Fatal error - browser is not usable without CDP connection
self.logger.error(f'❌ FATAL: Failed to setup CDP connection: {e}')
self.logger.error('❌ Browser cannot continue without CDP connection')
# Clear SessionManager state
if self.session_manager:
try:
await self.session_manager.clear()
self.logger.debug('Cleared SessionManager state after initialization failure')
except Exception as cleanup_error:
self.logger.debug(f'Error clearing SessionManager: {cleanup_error}')
# Close CDP client WebSocket and unregister handlers
if self._cdp_client_root:
try:
await self._cdp_client_root.stop() # Close WebSocket and unregister handlers
self.logger.debug('Closed CDP client WebSocket after initialization failure')
except Exception as cleanup_error:
self.logger.debug(f'Error closing CDP client: {cleanup_error}')
self.session_manager = None
self._cdp_client_root = None
self.agent_focus_target_id = None
# Re-raise as a fatal error
raise RuntimeError(f'Failed to establish CDP connection to browser: {e}') from e
return self
async def _setup_proxy_auth(self) -> None:
"""Enable CDP Fetch auth handling for authenticated proxy, if credentials provided.
Handles HTTP proxy authentication challenges (Basic/Proxy) by providing
configured credentials from BrowserProfile.
"""
assert self._cdp_client_root
try:
proxy_cfg = self.browser_profile.proxy
username = proxy_cfg.username if proxy_cfg else None
password = proxy_cfg.password if proxy_cfg else None
if not username or not password:
self.logger.debug('Proxy credentials not provided; skipping proxy auth setup')
return
# Enable Fetch domain with auth handling (do not pause all requests)
try:
await self._cdp_client_root.send.Fetch.enable(params={'handleAuthRequests': True})
self.logger.debug('Fetch.enable(handleAuthRequests=True) enabled on root client')
except Exception as e:
self.logger.debug(f'Fetch.enable on root failed: {type(e).__name__}: {e}')
# Also enable on the focused target's session if available to ensure events are delivered
try:
if self.agent_focus_target_id:
cdp_session = await self.get_or_create_cdp_session(self.agent_focus_target_id, focus=False)
await cdp_session.cdp_client.send.Fetch.enable(
params={'handleAuthRequests': True},
session_id=cdp_session.session_id,
)
self.logger.debug('Fetch.enable(handleAuthRequests=True) enabled on focused session')
except Exception as e:
self.logger.debug(f'Fetch.enable on focused session failed: {type(e).__name__}: {e}')
def _on_auth_required(event: AuthRequiredEvent, session_id: SessionID | None = None):
# event keys may be snake_case or camelCase depending on generator; handle both
request_id = event.get('requestId') or event.get('request_id')
if not request_id:
return
challenge = event.get('authChallenge') or event.get('auth_challenge') or {}
source = (challenge.get('source') or '').lower()
# Only respond to proxy challenges
if source == 'proxy' and request_id:
async def _respond():
assert self._cdp_client_root
try:
await self._cdp_client_root.send.Fetch.continueWithAuth(
params={
'requestId': request_id,
'authChallengeResponse': {
'response': 'ProvideCredentials',
'username': username,
'password': password,
},
},
session_id=session_id,
)
except Exception as e:
self.logger.debug(f'Proxy auth respond failed: {type(e).__name__}: {e}')
# schedule
create_task_with_error_handling(
_respond(), name='auth_respond', logger_instance=self.logger, suppress_exceptions=True
)
else:
# Default behaviour for non-proxy challenges: let browser handle
async def _default():
assert self._cdp_client_root
try:
await self._cdp_client_root.send.Fetch.continueWithAuth(
params={'requestId': request_id, 'authChallengeResponse': {'response': 'Default'}},
session_id=session_id,
)
except Exception as e:
self.logger.debug(f'Default auth respond failed: {type(e).__name__}: {e}')
if request_id:
create_task_with_error_handling(
_default(), name='auth_default', logger_instance=self.logger, suppress_exceptions=True
)
def _on_request_paused(event: RequestPausedEvent, session_id: SessionID | None = None):
# Continue all paused requests to avoid stalling the network
request_id = event.get('requestId') or event.get('request_id')
if not request_id:
return
async def _continue():
assert self._cdp_client_root
try:
await self._cdp_client_root.send.Fetch.continueRequest(
params={'requestId': request_id},
session_id=session_id,
)
except Exception:
pass
create_task_with_error_handling(
_continue(), name='request_continue', logger_instance=self.logger, suppress_exceptions=True
)
# Register event handler on root client
try:
self._cdp_client_root.register.Fetch.authRequired(_on_auth_required)
self._cdp_client_root.register.Fetch.requestPaused(_on_request_paused)
if self.agent_focus_target_id:
cdp_session = await self.get_or_create_cdp_session(self.agent_focus_target_id, focus=False)
cdp_session.cdp_client.register.Fetch.authRequired(_on_auth_required)
cdp_session.cdp_client.register.Fetch.requestPaused(_on_request_paused)
self.logger.debug('Registered Fetch.authRequired handlers')
except Exception as e:
self.logger.debug(f'Failed to register authRequired handlers: {type(e).__name__}: {e}')
# Ensure Fetch is enabled for the current focused target's session, too
try:
if self.agent_focus_target_id:
# Use safe API with focus=False to avoid changing focus
cdp_session = await self.get_or_create_cdp_session(self.agent_focus_target_id, focus=False)
await cdp_session.cdp_client.send.Fetch.enable(
params={'handleAuthRequests': True, 'patterns': [{'urlPattern': '*'}]},
session_id=cdp_session.session_id,
)
except Exception as e:
self.logger.debug(f'Fetch.enable on focused session failed: {type(e).__name__}: {e}')
except Exception as e:
self.logger.debug(f'Skipping proxy auth setup: {type(e).__name__}: {e}')
async def reconnect(self) -> None:
"""Re-establish the CDP WebSocket connection to an already-running browser.
This is a lightweight reconnection that:
1. Stops the old CDPClient (WS already dead, just clean state)
2. Clears SessionManager (all CDP sessions are invalid post-disconnect)
3. Creates a new CDPClient with the same cdp_url
4. Re-initializes SessionManager and re-enables autoAttach
5. Re-discovers page targets and restores agent focus
6. Re-enables proxy auth if configured
"""
assert self.cdp_url, 'Cannot reconnect without a CDP URL'
old_focus_target_id = self.agent_focus_target_id
# 1. Stop old CDPClient (WS is already dead, this just cleans internal state)
if self._cdp_client_root:
try:
await self._cdp_client_root.stop()
except Exception as e:
self.logger.debug(f'Error stopping old CDP client during reconnect: {e}')
self._cdp_client_root = None
# 2. Clear SessionManager (all sessions are stale)
if self.session_manager:
try:
await self.session_manager.clear()
except Exception as e:
self.logger.debug(f'Error clearing SessionManager during reconnect: {e}')
self.session_manager = None
self.agent_focus_target_id = None
# 3. Create new CDPClient with the same cdp_url
headers = dict(getattr(self.browser_profile, 'headers', None) or {})
if not self.is_local:
from browser_use.utils import get_browser_use_version
headers.setdefault('User-Agent', f'browser-use/{get_browser_use_version()}')
self._cdp_client_root = TimeoutWrappedCDPClient(
self.cdp_url,
additional_headers=headers or None,
max_ws_frame_size=200 * 1024 * 1024,
)
await self._cdp_client_root.start()
# 4. Re-initialize SessionManager
from browser_use.browser.session_manager import SessionManager
self.session_manager = SessionManager(self)
await self.session_manager.start_monitoring()
# 5. Re-enable autoAttach
await self._cdp_client_root.send.Target.setAutoAttach(
params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}
)
# 6. Re-discover page targets and restore focus
page_targets = self.session_manager.get_all_page_targets()
# Prefer the old focus target if it still exists
restored = False
if old_focus_target_id:
for target in page_targets:
if target.target_id == old_focus_target_id:
await self.get_or_create_cdp_session(old_focus_target_id, focus=True)
restored = True
self.logger.debug(f'🔄 Restored agent focus to previous target {old_focus_target_id[:8]}...')
break
if not restored:
if page_targets:
fallback_id = page_targets[0].target_id
await self.get_or_create_cdp_session(fallback_id, focus=True)
self.logger.debug(f'🔄 Agent focus set to fallback target {fallback_id[:8]}...')
else:
# No pages exist — create one
new_target = await self._cdp_client_root.send.Target.createTarget(params={'url': 'about:blank'})
target_id = new_target['targetId']
await self.get_or_create_cdp_session(target_id, focus=True)
self.logger.debug(f'🔄 Created new blank page during reconnect: {target_id[:8]}...')
# 7. Re-enable proxy auth if configured
await self._setup_proxy_auth()
# 8. Attach the WS drop detection callback to the new client
self._attach_ws_drop_callback()
async def _auto_reconnect(self, max_attempts: int = 3) -> None:
"""Attempt to reconnect with exponential backoff.
Dispatches BrowserReconnectingEvent before each attempt and
BrowserReconnectedEvent on success.
"""
if self._intentional_stop or not self.cdp_url:
return
async with self._reconnect_lock:
if self._reconnecting:
return # already in progress from another caller
self._reconnecting = True
self._reconnect_event.clear()
start_time = time.time()
delays = [1.0, 2.0, 4.0]
try:
for attempt in range(1, max_attempts + 1):
if self._intentional_stop or not self.cdp_url:
return
self.event_bus.dispatch(
BrowserReconnectingEvent(
cdp_url=self.cdp_url or '',
attempt=attempt,
max_attempts=max_attempts,
)
)
self.logger.warning(f'🔄 WebSocket reconnection attempt {attempt}/{max_attempts}...')
try:
await asyncio.wait_for(self.reconnect(), timeout=15.0)
# Success
downtime = time.time() - start_time
self.event_bus.dispatch(
BrowserReconnectedEvent(
cdp_url=self.cdp_url or '',
attempt=attempt,
downtime_seconds=downtime,
)
)
self.logger.info(f'🔄 WebSocket reconnected after {downtime:.1f}s (attempt {attempt})')
return
except Exception as e:
self.logger.warning(f'🔄 Reconnection attempt {attempt} failed: {type(e).__name__}: {e}')
if attempt < max_attempts:
delay = delays[attempt - 1] if attempt - 1 < len(delays) else delays[-1]
await asyncio.sleep(delay)
# All attempts exhausted
self.logger.error(f'🔄 All {max_attempts} reconnection attempts failed')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='ReconnectionFailed',
message=f'Failed to reconnect after {max_attempts} attempts ({time.time() - start_time:.1f}s)',
details={'cdp_url': self.cdp_url or '', 'max_attempts': max_attempts},
)
)
finally:
self._reconnecting = False
self._reconnect_event.set() # wake up all waiters regardless of outcome
def _attach_ws_drop_callback(self) -> None:
"""Attach a done callback to the CDPClient's message handler task to detect WS drops."""
if not self._cdp_client_root or not hasattr(self._cdp_client_root, '_message_handler_task'):
return
task = self._cdp_client_root._message_handler_task
if task is None or task.done():
return
def _on_message_handler_done(fut: asyncio.Future) -> None:
# Guard: skip if intentionally stopped, already reconnecting, or no cdp_url
if self._intentional_stop or self._reconnecting or not self.cdp_url:
return
# The message handler task exiting means the WS connection dropped
exc = fut.exception() if not fut.cancelled() else None
self.logger.warning(
f'🔌 CDP WebSocket message handler exited unexpectedly'
f'{f": {type(exc).__name__}: {exc}" if exc else " (connection closed)"}'
)
# Fire auto-reconnect as an asyncio task
try:
loop = asyncio.get_running_loop()
self._reconnect_task = loop.create_task(self._auto_reconnect())
except RuntimeError:
# No running event loop — can't reconnect
self.logger.error('🔌 No event loop available for auto-reconnect')
task.add_done_callback(_on_message_handler_done)
async def _close_extension_options_pages(self) -> None:
"""Close any extension options/welcome pages that have opened."""
try:
# Get all page targets from SessionManager
page_targets = self.session_manager.get_all_page_targets()
for target in page_targets:
target_url = target.url
target_id = target.target_id
# Check if this is an extension options/welcome page
if 'chrome-extension://' in target_url and (
'options.html' in target_url or 'welcome.html' in target_url or 'onboarding.html' in target_url
):
self.logger.info(f'[BrowserSession] 🚫 Closing extension options page: {target_url}')
try:
await self._cdp_close_page(target_id)
except Exception as e:
self.logger.debug(f'[BrowserSession] Could not close extension page {target_id}: {e}')
except Exception as e:
self.logger.debug(f'[BrowserSession] Error closing extension options pages: {e}')
async def send_demo_mode_log(self, message: str, level: str = 'info', metadata: dict[str, Any] | None = None) -> None:
"""Send a message to the in-browser demo panel if enabled."""
if not self.browser_profile.demo_mode:
return
demo = self.demo_mode
if not demo:
return
try:
await demo.send_log(message=message, level=level, metadata=metadata or {})
except Exception as exc:
self.logger.debug(f'[DemoMode] Failed to send log: {exc}')
@property
def downloaded_files(self) -> list[str]:
"""Get list of files downloaded during this browser session.
Returns:
list[str]: List of absolute file paths to downloaded files in this session
"""
return self._downloaded_files.copy()
# endregion - ========== Helper Methods ==========