ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,41 @@
from typing import TYPE_CHECKING
# Type stubs for lazy imports
if TYPE_CHECKING:
from .profile import BrowserProfile, ProxySettings
from .session import BrowserSession
# Lazy imports mapping for heavy browser components
_LAZY_IMPORTS = {
'ProxySettings': ('.profile', 'ProxySettings'),
'BrowserProfile': ('.profile', 'BrowserProfile'),
'BrowserSession': ('.session', 'BrowserSession'),
}
def __getattr__(name: str):
"""Lazy import mechanism for heavy browser components."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
try:
from importlib import import_module
# Use relative import for current package
full_module_path = f'browser_use.browser{module_path}'
module = import_module(full_module_path)
attr = getattr(module, attr_name)
# Cache the imported attribute in the module's globals
globals()[name] = attr
return attr
except ImportError as e:
raise ImportError(f'Failed to import {name} from {full_module_path}: {e}') from e
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = [
'BrowserSession',
'BrowserProfile',
'ProxySettings',
]
@@ -0,0 +1,287 @@
"""Cloud browser service integration for browser-use.
This module provides integration with the browser-use cloud browser service.
When cloud_browser=True, it automatically creates a cloud browser instance
and returns the CDP URL for connection.
"""
import logging
import os
import httpx
from pydantic import BaseModel, Field
from browser_use.sync.auth import CloudAuthConfig
logger = logging.getLogger(__name__)
class CloudBrowserResponse(BaseModel):
"""Response from cloud browser API."""
id: str
status: str
liveUrl: str = Field(alias='liveUrl')
cdpUrl: str = Field(alias='cdpUrl')
timeoutAt: str = Field(alias='timeoutAt')
startedAt: str = Field(alias='startedAt')
finishedAt: str | None = Field(alias='finishedAt', default=None)
class CloudBrowserError(Exception):
"""Exception raised when cloud browser operations fail."""
pass
class CloudBrowserAuthError(CloudBrowserError):
"""Exception raised when cloud browser authentication fails."""
pass
class CloudBrowserClient:
"""Client for browser-use cloud browser service."""
def __init__(self, api_base_url: str = 'https://api.browser-use.com'):
self.api_base_url = api_base_url
self.client = httpx.AsyncClient(timeout=30.0)
self.current_session_id: str | None = None
async def create_browser(self) -> CloudBrowserResponse:
"""Create a new cloud browser instance.
Returns:
CloudBrowserResponse: Contains CDP URL and other browser info
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If browser creation fails
"""
url = f'{self.api_base_url}/api/v2/browsers'
# Try to get API key from environment variable first, then auth config
api_token = os.getenv('BROWSER_USE_API_KEY')
if not api_token:
# Fallback to auth config file
try:
auth_config = CloudAuthConfig.load_from_file()
api_token = auth_config.api_token
except Exception:
pass
if not api_token:
raise CloudBrowserAuthError(
'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
# Empty request body as per API specification
request_body = {}
try:
logger.info('🌤️ Creating cloud browser instance...')
response = await self.client.post(url, headers=headers, json=request_body)
if response.status_code == 401:
raise CloudBrowserAuthError(
'Authentication failed. Please make sure you have set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
elif response.status_code == 403:
raise CloudBrowserAuthError('Access forbidden. Please check your browser-use cloud subscription status.')
elif not response.is_success:
error_msg = f'Failed to create cloud browser: HTTP {response.status_code}'
try:
error_data = response.json()
if 'detail' in error_data:
error_msg += f' - {error_data["detail"]}'
except Exception:
pass
raise CloudBrowserError(error_msg)
browser_data = response.json()
browser_response = CloudBrowserResponse(**browser_data)
# Store session ID for cleanup
self.current_session_id = browser_response.id
logger.info(f'🌤️ Cloud browser created successfully: {browser_response.id}')
logger.debug(f'🌤️ CDP URL: {browser_response.cdpUrl}')
# Cyan color for live URL
logger.info(f'\033[36m🔗 Live URL: {browser_response.liveUrl}\033[0m')
return browser_response
except httpx.TimeoutException:
raise CloudBrowserError('Timeout while creating cloud browser. Please try again.')
except httpx.ConnectError:
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
except Exception as e:
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
raise
raise CloudBrowserError(f'Unexpected error creating cloud browser: {e}')
async def stop_browser(self, session_id: str | None = None) -> CloudBrowserResponse:
"""Stop a cloud browser session.
Args:
session_id: Session ID to stop. If None, uses current session.
Returns:
CloudBrowserResponse: Updated browser info with stopped status
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If stopping fails
"""
if session_id is None:
session_id = self.current_session_id
if not session_id:
raise CloudBrowserError('No session ID provided and no current session available')
url = f'{self.api_base_url}/api/v2/browsers/{session_id}'
# Try to get API key from environment variable first, then auth config
api_token = os.getenv('BROWSER_USE_API_KEY')
if not api_token:
# Fallback to auth config file
try:
auth_config = CloudAuthConfig.load_from_file()
api_token = auth_config.api_token
except Exception:
pass
if not api_token:
raise CloudBrowserAuthError(
'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
request_body = {'action': 'stop'}
try:
logger.info(f'🌤️ Stopping cloud browser session: {session_id}')
response = await self.client.patch(url, headers=headers, json=request_body)
if response.status_code == 401:
raise CloudBrowserAuthError(
'Authentication failed. Please make sure you have set the BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
)
elif response.status_code == 404:
# Session already stopped or doesn't exist - treating as error and clearing session
logger.debug(f'🌤️ Cloud browser session {session_id} not found (already stopped)')
# Clear current session if it was this one
if session_id == self.current_session_id:
self.current_session_id = None
raise CloudBrowserError(f'Cloud browser session {session_id} not found')
elif not response.is_success:
error_msg = f'Failed to stop cloud browser: HTTP {response.status_code}'
try:
error_data = response.json()
if 'detail' in error_data:
error_msg += f' - {error_data["detail"]}'
except Exception:
pass
raise CloudBrowserError(error_msg)
browser_data = response.json()
browser_response = CloudBrowserResponse(**browser_data)
# Clear current session if it was this one
if session_id == self.current_session_id:
self.current_session_id = None
logger.info(f'🌤️ Cloud browser session stopped: {browser_response.id}')
logger.debug(f'🌤️ Status: {browser_response.status}')
return browser_response
except httpx.TimeoutException:
raise CloudBrowserError('Timeout while stopping cloud browser. Please try again.')
except httpx.ConnectError:
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
except Exception as e:
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
raise
raise CloudBrowserError(f'Unexpected error stopping cloud browser: {e}')
async def close(self):
"""Close the HTTP client and cleanup any active sessions."""
# Try to stop current session if active
if self.current_session_id:
try:
await self.stop_browser()
except Exception as e:
logger.debug(f'Failed to stop cloud browser session during cleanup: {e}')
await self.client.aclose()
# Global client instance
_cloud_client: CloudBrowserClient | None = None
async def get_cloud_browser_cdp_url() -> str:
"""Get a CDP URL for a new cloud browser instance.
Returns:
str: CDP URL for connecting to the cloud browser
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If browser creation fails
"""
global _cloud_client
if _cloud_client is None:
_cloud_client = CloudBrowserClient()
try:
browser_response = await _cloud_client.create_browser()
return browser_response.cdpUrl
except Exception:
# Clean up client on error
if _cloud_client:
await _cloud_client.close()
_cloud_client = None
raise
async def stop_cloud_browser_session(session_id: str | None = None) -> CloudBrowserResponse:
"""Stop a cloud browser session.
Args:
session_id: Session ID to stop. If None, uses current session from global client.
Returns:
CloudBrowserResponse: Updated browser info with stopped status
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If stopping fails
"""
global _cloud_client
if _cloud_client is None:
_cloud_client = CloudBrowserClient()
try:
return await _cloud_client.stop_browser(session_id)
except Exception:
# Don't clean up client on stop errors - session might still be valid
raise
async def cleanup_cloud_client():
"""Clean up the global cloud client."""
global _cloud_client
if _cloud_client:
await _cloud_client.close()
_cloud_client = None
@@ -0,0 +1,584 @@
"""Event definitions for browser communication."""
import inspect
import os
from typing import Any, Literal
from bubus import BaseEvent
from bubus.models import T_EventResultType
from cdp_use.cdp.target import TargetID
from pydantic import BaseModel, Field, field_validator
from browser_use.browser.views import BrowserStateSummary
from browser_use.dom.views import EnhancedDOMTreeNode
def _get_timeout(env_var: str, default: float) -> float | None:
"""
Safely parse environment variable timeout values with robust error handling.
Args:
env_var: Environment variable name (e.g. 'TIMEOUT_NavigateToUrlEvent')
default: Default timeout value as float (e.g. 15.0)
Returns:
Parsed float value or the default if parsing fails
Raises:
ValueError: Only if both env_var and default are invalid (should not happen with valid defaults)
"""
# Try environment variable first
env_value = os.getenv(env_var)
if env_value:
try:
parsed = float(env_value)
if parsed < 0:
print(f'Warning: {env_var}={env_value} is negative, using default {default}')
return default
return parsed
except (ValueError, TypeError):
print(f'Warning: {env_var}={env_value} is not a valid number, using default {default}')
# Fall back to default
return default
# ============================================================================
# Agent/Tools -> BrowserSession Events (High-level browser actions)
# ============================================================================
class ElementSelectedEvent(BaseEvent[T_EventResultType]):
"""An element was selected."""
node: EnhancedDOMTreeNode
@field_validator('node', mode='before')
@classmethod
def serialize_node(cls, data: EnhancedDOMTreeNode | None) -> EnhancedDOMTreeNode | None:
if data is None:
return None
return EnhancedDOMTreeNode(
element_index=data.element_index,
node_id=data.node_id,
backend_node_id=data.backend_node_id,
session_id=data.session_id,
frame_id=data.frame_id,
target_id=data.target_id,
node_type=data.node_type,
node_name=data.node_name,
node_value=data.node_value,
attributes=data.attributes,
is_scrollable=data.is_scrollable,
is_visible=data.is_visible,
absolute_position=data.absolute_position,
# override the circular reference fields in EnhancedDOMTreeNode as they cant be serialized and aren't needed by event handlers
# only used internally by the DOM service during DOM tree building process, not intended public API use
content_document=None,
shadow_root_type=None,
shadow_roots=[],
parent_node=None,
children_nodes=[],
ax_node=None,
snapshot_node=None,
)
# TODO: add page handle to events
# class PageHandle(share a base with browser.session.CDPSession?):
# url: str
# target_id: TargetID
# @classmethod
# def from_target_id(cls, target_id: TargetID) -> Self:
# return cls(target_id=target_id)
# @classmethod
# def from_target_id(cls, target_id: TargetID) -> Self:
# return cls(target_id=target_id)
# @classmethod
# def from_url(cls, url: str) -> Self:
# @property
# def root_frame_id(self) -> str:
# return self.target_id
# @property
# def session_id(self) -> str:
# return browser_session.get_or_create_cdp_session(self.target_id).session_id
# class PageSelectedEvent(BaseEvent[T_EventResultType]):
# """An event like SwitchToTabEvent(page=PageHandle) or CloseTabEvent(page=PageHandle)"""
# page: PageHandle
class NavigateToUrlEvent(BaseEvent[None]):
"""Navigate to a specific URL."""
url: str
wait_until: Literal['load', 'domcontentloaded', 'networkidle', 'commit'] = 'load'
timeout_ms: int | None = None
new_tab: bool = Field(
default=False, description='Set True to leave the current tab alone and open a new tab in the foreground for the new URL'
)
# existing_tab: PageHandle | None = None # TODO
# time limits enforced by bubus, not exposed to LLM:
event_timeout: float | None = _get_timeout('TIMEOUT_NavigateToUrlEvent', 15.0) # seconds
class ClickElementEvent(ElementSelectedEvent[dict[str, Any] | None]):
"""Click an element."""
node: 'EnhancedDOMTreeNode'
button: Literal['left', 'right', 'middle'] = 'left'
while_holding_ctrl: bool = Field(
default=False,
description='Set True to open any link clicked in a new tab in the background, can use switch_tab(tab_id=None) after to focus it',
)
# click_count: int = 1 # TODO
# expect_download: bool = False # moved to downloads_watchdog.py
event_timeout: float | None = _get_timeout('TIMEOUT_ClickElementEvent', 15.0) # seconds
class TypeTextEvent(ElementSelectedEvent[dict | None]):
"""Type text into an element."""
node: 'EnhancedDOMTreeNode'
text: str
clear_existing: bool = True
is_sensitive: bool = False # Flag to indicate if text contains sensitive data
sensitive_key_name: str | None = None # Name of the sensitive key being typed (e.g., 'username', 'password')
event_timeout: float | None = _get_timeout('TIMEOUT_TypeTextEvent', 15.0) # seconds
class ScrollEvent(ElementSelectedEvent[None]):
"""Scroll the page or element."""
direction: Literal['up', 'down', 'left', 'right']
amount: int # pixels
node: 'EnhancedDOMTreeNode | None' = None # None means scroll page
event_timeout: float | None = _get_timeout('TIMEOUT_ScrollEvent', 8.0) # seconds
class SwitchTabEvent(BaseEvent[TargetID]):
"""Switch to a different tab."""
target_id: TargetID | None = Field(default=None, description='None means switch to the most recently opened tab')
event_timeout: float | None = _get_timeout('TIMEOUT_SwitchTabEvent', 10.0) # seconds
class CloseTabEvent(BaseEvent[None]):
"""Close a tab."""
target_id: TargetID
event_timeout: float | None = _get_timeout('TIMEOUT_CloseTabEvent', 10.0) # seconds
class ScreenshotEvent(BaseEvent[str]):
"""Request to take a screenshot."""
full_page: bool = False
clip: dict[str, float] | None = None # {x, y, width, height}
event_timeout: float | None = _get_timeout('TIMEOUT_ScreenshotEvent', 8.0) # seconds
class BrowserStateRequestEvent(BaseEvent[BrowserStateSummary]):
"""Request current browser state."""
include_dom: bool = True
include_screenshot: bool = True
cache_clickable_elements_hashes: bool = True
include_recent_events: bool = False
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStateRequestEvent', 30.0) # seconds
# class WaitForConditionEvent(BaseEvent):
# """Wait for a condition."""
# condition: Literal['navigation', 'selector', 'timeout', 'load_state']
# timeout: float = 30000
# selector: str | None = None
# state: Literal['attached', 'detached', 'visible', 'hidden'] | None = None
class GoBackEvent(BaseEvent[None]):
"""Navigate back in browser history."""
event_timeout: float | None = _get_timeout('TIMEOUT_GoBackEvent', 15.0) # seconds
class GoForwardEvent(BaseEvent[None]):
"""Navigate forward in browser history."""
event_timeout: float | None = _get_timeout('TIMEOUT_GoForwardEvent', 15.0) # seconds
class RefreshEvent(BaseEvent[None]):
"""Refresh/reload the current page."""
event_timeout: float | None = _get_timeout('TIMEOUT_RefreshEvent', 15.0) # seconds
class WaitEvent(BaseEvent[None]):
"""Wait for a specified number of seconds."""
seconds: float = 3.0
max_seconds: float = 10.0 # Safety cap
event_timeout: float | None = _get_timeout('TIMEOUT_WaitEvent', 60.0) # seconds
class SendKeysEvent(BaseEvent[None]):
"""Send keyboard keys/shortcuts."""
keys: str # e.g., "ctrl+a", "cmd+c", "Enter"
event_timeout: float | None = _get_timeout('TIMEOUT_SendKeysEvent', 15.0) # seconds
class UploadFileEvent(ElementSelectedEvent[None]):
"""Upload a file to an element."""
node: 'EnhancedDOMTreeNode'
file_path: str
event_timeout: float | None = _get_timeout('TIMEOUT_UploadFileEvent', 30.0) # seconds
class GetDropdownOptionsEvent(ElementSelectedEvent[dict[str, str]]):
"""Get all options from any dropdown (native <select>, ARIA menus, or custom dropdowns).
Returns a dict containing dropdown type, options list, and element metadata."""
node: 'EnhancedDOMTreeNode'
event_timeout: float | None = _get_timeout(
'TIMEOUT_GetDropdownOptionsEvent',
15.0,
) # some dropdowns lazy-load the list of options on first interaction, so we need to wait for them to load (e.g. table filter lists can have thousands of options)
class SelectDropdownOptionEvent(ElementSelectedEvent[dict[str, str]]):
"""Select a dropdown option by exact text from any dropdown type.
Returns a dict containing success status and selection details."""
node: 'EnhancedDOMTreeNode'
text: str # The option text to select
event_timeout: float | None = _get_timeout('TIMEOUT_SelectDropdownOptionEvent', 8.0) # seconds
class ScrollToTextEvent(BaseEvent[None]):
"""Scroll to specific text on the page. Raises exception if text not found."""
text: str
direction: Literal['up', 'down'] = 'down'
event_timeout: float | None = _get_timeout('TIMEOUT_ScrollToTextEvent', 15.0) # seconds
# ============================================================================
class BrowserStartEvent(BaseEvent):
"""Start/connect to browser."""
cdp_url: str | None = None
launch_options: dict[str, Any] = Field(default_factory=dict)
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStartEvent', 30.0) # seconds
class BrowserStopEvent(BaseEvent):
"""Stop/disconnect from browser."""
force: bool = False
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStopEvent', 45.0) # seconds
class BrowserLaunchResult(BaseModel):
"""Result of launching a browser."""
# TODO: add browser executable_path, pid, version, latency, user_data_dir, X11 $DISPLAY, host IP address, etc.
cdp_url: str
class BrowserLaunchEvent(BaseEvent[BrowserLaunchResult]):
"""Launch a local browser process."""
# TODO: add executable_path, proxy settings, preferences, extra launch args, etc.
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserLaunchEvent', 30.0) # seconds
class BrowserKillEvent(BaseEvent):
"""Kill local browser subprocess."""
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserKillEvent', 30.0) # seconds
# TODO: replace all Runtime.evaluate() calls with this event
# class ExecuteJavaScriptEvent(BaseEvent):
# """Execute JavaScript in page context."""
# target_id: TargetID
# expression: str
# await_promise: bool = True
# event_timeout: float | None = 60.0 # seconds
# TODO: add this and use the old BrowserProfile.viewport options to set it
# class SetViewportEvent(BaseEvent):
# """Set the viewport size."""
# width: int
# height: int
# device_scale_factor: float = 1.0
# event_timeout: float | None = 15.0 # seconds
# Moved to storage state
# class SetCookiesEvent(BaseEvent):
# """Set browser cookies."""
# cookies: list[dict[str, Any]]
# event_timeout: float | None = (
# 30.0 # only long to support the edge case of restoring a big localStorage / on many origins (has to O(n) visit each origin to restore)
# )
# class GetCookiesEvent(BaseEvent):
# """Get browser cookies."""
# urls: list[str] | None = None
# event_timeout: float | None = 30.0 # seconds
# ============================================================================
# DOM-related Events
# ============================================================================
class BrowserConnectedEvent(BaseEvent):
"""Browser has started/connected."""
cdp_url: str
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserConnectedEvent', 30.0) # seconds
class BrowserStoppedEvent(BaseEvent):
"""Browser has stopped/disconnected."""
reason: str | None = None
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStoppedEvent', 30.0) # seconds
class TabCreatedEvent(BaseEvent):
"""A new tab was created."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_TabCreatedEvent', 30.0) # seconds
class TabClosedEvent(BaseEvent):
"""A tab was closed."""
target_id: TargetID
# TODO:
# new_focus_target_id: int | None = None
# new_focus_url: str | None = None
event_timeout: float | None = _get_timeout('TIMEOUT_TabClosedEvent', 10.0) # seconds
# TODO: emit this when DOM changes significantly, inner frame navigates, form submits, history.pushState(), etc.
# class TabUpdatedEvent(BaseEvent):
# """Tab information updated (URL changed, etc.)."""
# target_id: TargetID
# url: str
class AgentFocusChangedEvent(BaseEvent):
"""Agent focus changed to a different tab."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_AgentFocusChangedEvent', 10.0) # seconds
class TargetCrashedEvent(BaseEvent):
"""A target has crashed."""
target_id: TargetID
error: str
event_timeout: float | None = _get_timeout('TIMEOUT_TargetCrashedEvent', 10.0) # seconds
class NavigationStartedEvent(BaseEvent):
"""Navigation started."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_NavigationStartedEvent', 30.0) # seconds
class NavigationCompleteEvent(BaseEvent):
"""Navigation completed."""
target_id: TargetID
url: str
status: int | None = None
error_message: str | None = None # Error/timeout message if navigation had issues
loading_status: str | None = None # Detailed loading status (e.g., network timeout info)
event_timeout: float | None = _get_timeout('TIMEOUT_NavigationCompleteEvent', 30.0) # seconds
# ============================================================================
# Error Events
# ============================================================================
class BrowserErrorEvent(BaseEvent):
"""An error occurred in the browser layer."""
error_type: str
message: str
details: dict[str, Any] = Field(default_factory=dict)
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserErrorEvent', 30.0) # seconds
# ============================================================================
# Storage State Events
# ============================================================================
class SaveStorageStateEvent(BaseEvent):
"""Request to save browser storage state."""
path: str | None = None # Optional path, uses profile default if not provided
event_timeout: float | None = _get_timeout('TIMEOUT_SaveStorageStateEvent', 45.0) # seconds
class StorageStateSavedEvent(BaseEvent):
"""Notification that storage state was saved."""
path: str
cookies_count: int
origins_count: int
event_timeout: float | None = _get_timeout('TIMEOUT_StorageStateSavedEvent', 30.0) # seconds
class LoadStorageStateEvent(BaseEvent):
"""Request to load browser storage state."""
path: str | None = None # Optional path, uses profile default if not provided
event_timeout: float | None = _get_timeout('TIMEOUT_LoadStorageStateEvent', 45.0) # seconds
# TODO: refactor this to:
# - on_BrowserConnectedEvent() -> dispatch(LoadStorageStateEvent()) -> _copy_storage_state_from_json_to_browser(json_file, new_cdp_session) + return storage_state from handler
# - on_BrowserStopEvent() -> dispatch(SaveStorageStateEvent()) -> _copy_storage_state_from_browser_to_json(new_cdp_session, json_file)
# and get rid of StorageStateSavedEvent and StorageStateLoadedEvent, have the original events + provide handler return values for any results
class StorageStateLoadedEvent(BaseEvent):
"""Notification that storage state was loaded."""
path: str
cookies_count: int
origins_count: int
event_timeout: float | None = _get_timeout('TIMEOUT_StorageStateLoadedEvent', 30.0) # seconds
# ============================================================================
# File Download Events
# ============================================================================
class FileDownloadedEvent(BaseEvent):
"""A file has been downloaded."""
url: str
path: str
file_name: str
file_size: int
file_type: str | None = None # e.g., 'pdf', 'zip', 'docx', etc.
mime_type: str | None = None # e.g., 'application/pdf'
from_cache: bool = False
auto_download: bool = False # Whether this was an automatic download (e.g., PDF auto-download)
event_timeout: float | None = _get_timeout('TIMEOUT_FileDownloadedEvent', 30.0) # seconds
class AboutBlankDVDScreensaverShownEvent(BaseEvent):
"""AboutBlankWatchdog has shown DVD screensaver animation on an about:blank tab."""
target_id: TargetID
error: str | None = None
class DialogOpenedEvent(BaseEvent):
"""Event dispatched when a JavaScript dialog is opened and handled."""
dialog_type: str # 'alert', 'confirm', 'prompt', or 'beforeunload'
message: str
url: str
frame_id: str | None = None # Can be None when frameId is not provided by CDP
# target_id: TargetID # TODO: add this to avoid needing target_id_from_frame() later
# Note: Model rebuilding for forward references is handled in the importing modules
# Events with 'EnhancedDOMTreeNode' forward references (ClickElementEvent, TypeTextEvent,
# ScrollEvent, UploadFileEvent) need model_rebuild() called after imports are complete
def _check_event_names_dont_overlap():
"""
check that event names defined in this file are valid and non-overlapping
(naiively n^2 so it's pretty slow but ok for now, optimize when >20 events)
"""
event_names = {
name.split('[')[0]
for name in globals().keys()
if not name.startswith('_')
and inspect.isclass(globals()[name])
and issubclass(globals()[name], BaseEvent)
and name != 'BaseEvent'
}
for name_a in event_names:
assert name_a.endswith('Event'), f'Event with name {name_a} does not end with "Event"'
for name_b in event_names:
if name_a != name_b: # Skip self-comparison
assert name_a not in name_b, (
f'Event with name {name_a} is a substring of {name_b}, all events must be completely unique to avoid find-and-replace accidents'
)
# overlapping event names are a nightmare to trace and rename later, dont do it!
# e.g. prevent ClickEvent and FailedClickEvent are terrible names because one is a substring of the other,
# must be ClickEvent and ClickFailedEvent to preserve the usefulnes of codebase grep/sed/awk as refactoring tools.
# at import time, we do a quick check that all event names defined above are valid and non-overlapping.
# this is hand written in blood by a human! not LLM slop. feel free to optimize but do not remove it without a good reason.
_check_event_names_dont_overlap()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,548 @@
"""Python-based highlighting system for drawing bounding boxes on screenshots.
This module replaces JavaScript-based highlighting with fast Python image processing
to draw bounding boxes around interactive elements directly on screenshots.
"""
import asyncio
import base64
import io
import logging
import os
from PIL import Image, ImageDraw, ImageFont
from browser_use.dom.views import DOMSelectorMap, EnhancedDOMTreeNode
from browser_use.observability import observe_debug
from browser_use.utils import time_execution_async
logger = logging.getLogger(__name__)
# Font cache to prevent repeated font loading and reduce memory usage
_FONT_CACHE: dict[tuple[str, int], ImageFont.FreeTypeFont | None] = {}
# Cross-platform font paths
_FONT_PATHS = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', # Linux (Debian/Ubuntu)
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', # Linux (Arch/Fedora)
'/System/Library/Fonts/Arial.ttf', # macOS
'C:\\Windows\\Fonts\\arial.ttf', # Windows
'arial.ttf', # Windows (system path)
'Arial Bold.ttf', # macOS alternative
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', # Linux alternative
]
def get_cross_platform_font(font_size: int) -> ImageFont.FreeTypeFont | None:
"""Get a cross-platform compatible font with caching to prevent memory leaks.
Args:
font_size: Size of the font to load
Returns:
ImageFont object or None if no system fonts are available
"""
# Use cache key based on font size
cache_key = ('system_font', font_size)
# Return cached font if available
if cache_key in _FONT_CACHE:
return _FONT_CACHE[cache_key]
# Try to load a system font
font = None
for font_path in _FONT_PATHS:
try:
font = ImageFont.truetype(font_path, font_size)
break
except OSError:
continue
# Cache the result (even if None) to avoid repeated attempts
_FONT_CACHE[cache_key] = font
return font
def cleanup_font_cache() -> None:
"""Clean up the font cache to prevent memory leaks in long-running applications."""
global _FONT_CACHE
_FONT_CACHE.clear()
# Color scheme for different element types
ELEMENT_COLORS = {
'button': '#FF6B6B', # Red for buttons
'input': '#4ECDC4', # Teal for inputs
'select': '#45B7D1', # Blue for dropdowns
'a': '#96CEB4', # Green for links
'textarea': '#FF8C42', # Orange for text areas (was yellow, now more visible)
'default': '#DDA0DD', # Light purple for other interactive elements
}
# Element type mappings
ELEMENT_TYPE_MAP = {
'button': 'button',
'input': 'input',
'select': 'select',
'a': 'a',
'textarea': 'textarea',
}
def get_element_color(tag_name: str, element_type: str | None = None) -> str:
"""Get color for element based on tag name and type."""
# Check input type first
if tag_name == 'input' and element_type:
if element_type in ['button', 'submit']:
return ELEMENT_COLORS['button']
# Use tag-based color
return ELEMENT_COLORS.get(tag_name.lower(), ELEMENT_COLORS['default'])
def should_show_index_overlay(element_index: int | None) -> bool:
"""Determine if index overlay should be shown."""
return element_index is not None
def draw_enhanced_bounding_box_with_text(
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
bbox: tuple[int, int, int, int],
color: str,
text: str | None = None,
font: ImageFont.FreeTypeFont | None = None,
element_type: str = 'div',
image_size: tuple[int, int] = (2000, 1500),
device_pixel_ratio: float = 1.0,
) -> None:
"""Draw an enhanced bounding box with much bigger index containers and dashed borders."""
x1, y1, x2, y2 = bbox
# Draw dashed bounding box with pattern: 1 line, 2 spaces, 1 line, 2 spaces...
dash_length = 4
gap_length = 8
line_width = 2
# Helper function to draw dashed line
def draw_dashed_line(start_x, start_y, end_x, end_y):
if start_x == end_x: # Vertical line
y = start_y
while y < end_y:
dash_end = min(y + dash_length, end_y)
draw.line([(start_x, y), (start_x, dash_end)], fill=color, width=line_width)
y += dash_length + gap_length
else: # Horizontal line
x = start_x
while x < end_x:
dash_end = min(x + dash_length, end_x)
draw.line([(x, start_y), (dash_end, start_y)], fill=color, width=line_width)
x += dash_length + gap_length
# Draw dashed rectangle
draw_dashed_line(x1, y1, x2, y1) # Top
draw_dashed_line(x2, y1, x2, y2) # Right
draw_dashed_line(x2, y2, x1, y2) # Bottom
draw_dashed_line(x1, y2, x1, y1) # Left
# Draw much bigger index overlay if we have index text
if text:
try:
# Scale font size for appropriate sizing across different resolutions
img_width, img_height = image_size
css_width = img_width # / device_pixel_ratio
# Much smaller scaling - 1% of CSS viewport width, max 16px to prevent huge highlights
base_font_size = max(10, min(20, int(css_width * 0.01)))
# Use shared font loading function with caching
big_font = get_cross_platform_font(base_font_size)
if big_font is None:
big_font = font # Fallback to original font if no system fonts found
# Get text size with bigger font
if big_font:
bbox_text = draw.textbbox((0, 0), text, font=big_font)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
else:
# Fallback for default font
bbox_text = draw.textbbox((0, 0), text)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
# Scale padding appropriately for different resolutions
padding = max(4, min(10, int(css_width * 0.005))) # 0.3% of CSS width, max 4px
element_width = x2 - x1
element_height = y2 - y1
# Container dimensions
container_width = text_width + padding * 2
container_height = text_height + padding * 2
# Position in top center - for small elements, place further up to avoid blocking content
# Center horizontally within the element
bg_x1 = x1 + (element_width - container_width) // 2
# Simple rule: if element is small, place index further up to avoid blocking icons
if element_width < 60 or element_height < 30:
# Small element: place well above to avoid blocking content
bg_y1 = max(0, y1 - container_height - 5)
else:
# Regular element: place inside with small offset
bg_y1 = y1 + 2
bg_x2 = bg_x1 + container_width
bg_y2 = bg_y1 + container_height
# Center the number within the index box with proper baseline handling
text_x = bg_x1 + (container_width - text_width) // 2
# Add extra vertical space to prevent clipping
text_y = bg_y1 + (container_height - text_height) // 2 - bbox_text[1] # Subtract top offset
# Ensure container stays within image bounds
img_width, img_height = image_size
if bg_x1 < 0:
offset = -bg_x1
bg_x1 += offset
bg_x2 += offset
text_x += offset
if bg_y1 < 0:
offset = -bg_y1
bg_y1 += offset
bg_y2 += offset
text_y += offset
if bg_x2 > img_width:
offset = bg_x2 - img_width
bg_x1 -= offset
bg_x2 -= offset
text_x -= offset
if bg_y2 > img_height:
offset = bg_y2 - img_height
bg_y1 -= offset
bg_y2 -= offset
text_y -= offset
# Draw bigger background rectangle with thicker border
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=color, outline='white', width=2)
# Draw white text centered in the index box
draw.text((text_x, text_y), text, fill='white', font=big_font or font)
except Exception as e:
logger.debug(f'Failed to draw enhanced text overlay: {e}')
def draw_bounding_box_with_text(
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
bbox: tuple[int, int, int, int],
color: str,
text: str | None = None,
font: ImageFont.FreeTypeFont | None = None,
) -> None:
"""Draw a bounding box with optional text overlay."""
x1, y1, x2, y2 = bbox
# Draw dashed bounding box
dash_length = 2
gap_length = 6
# Top edge
x = x1
while x < x2:
end_x = min(x + dash_length, x2)
draw.line([(x, y1), (end_x, y1)], fill=color, width=2)
draw.line([(x, y1 + 1), (end_x, y1 + 1)], fill=color, width=2)
x += dash_length + gap_length
# Bottom edge
x = x1
while x < x2:
end_x = min(x + dash_length, x2)
draw.line([(x, y2), (end_x, y2)], fill=color, width=2)
draw.line([(x, y2 - 1), (end_x, y2 - 1)], fill=color, width=2)
x += dash_length + gap_length
# Left edge
y = y1
while y < y2:
end_y = min(y + dash_length, y2)
draw.line([(x1, y), (x1, end_y)], fill=color, width=2)
draw.line([(x1 + 1, y), (x1 + 1, end_y)], fill=color, width=2)
y += dash_length + gap_length
# Right edge
y = y1
while y < y2:
end_y = min(y + dash_length, y2)
draw.line([(x2, y), (x2, end_y)], fill=color, width=2)
draw.line([(x2 - 1, y), (x2 - 1, end_y)], fill=color, width=2)
y += dash_length + gap_length
# Draw index overlay if we have index text
if text:
try:
# Get text size
if font:
bbox_text = draw.textbbox((0, 0), text, font=font)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
else:
# Fallback for default font
bbox_text = draw.textbbox((0, 0), text)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
# Smart positioning based on element size
padding = 5
element_width = x2 - x1
element_height = y2 - y1
element_area = element_width * element_height
index_box_area = (text_width + padding * 2) * (text_height + padding * 2)
# Calculate size ratio to determine positioning strategy
size_ratio = element_area / max(index_box_area, 1)
if size_ratio < 4:
# Very small elements: place outside in bottom-right corner
text_x = x2 + padding
text_y = y2 - text_height
# Ensure it doesn't go off screen
text_x = min(text_x, 1200 - text_width - padding)
text_y = max(text_y, 0)
elif size_ratio < 16:
# Medium elements: place in bottom-right corner inside
text_x = x2 - text_width - padding
text_y = y2 - text_height - padding
else:
# Large elements: place in center
text_x = x1 + (element_width - text_width) // 2
text_y = y1 + (element_height - text_height) // 2
# Ensure text stays within bounds
text_x = max(0, min(text_x, 1200 - text_width))
text_y = max(0, min(text_y, 800 - text_height))
# Draw background rectangle for maximum contrast
bg_x1 = text_x - padding
bg_y1 = text_y - padding
bg_x2 = text_x + text_width + padding
bg_y2 = text_y + text_height + padding
# Use white background with thick black border for maximum visibility
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill='white', outline='black', width=2)
# Draw bold dark text on light background for best contrast
draw.text((text_x, text_y), text, fill='black', font=font)
except Exception as e:
logger.debug(f'Failed to draw text overlay: {e}')
def process_element_highlight(
element_id: int,
element: EnhancedDOMTreeNode,
draw,
device_pixel_ratio: float,
font,
filter_highlight_ids: bool,
image_size: tuple[int, int],
) -> None:
"""Process a single element for highlighting."""
try:
# Use absolute_position coordinates directly
if not element.absolute_position:
return
bounds = element.absolute_position
# Scale coordinates from CSS pixels to device pixels for screenshot
# The screenshot is captured at device pixel resolution, but coordinates are in CSS pixels
x1 = int(bounds.x * device_pixel_ratio)
y1 = int(bounds.y * device_pixel_ratio)
x2 = int((bounds.x + bounds.width) * device_pixel_ratio)
y2 = int((bounds.y + bounds.height) * device_pixel_ratio)
# Ensure coordinates are within image bounds
img_width, img_height = image_size
x1 = max(0, min(x1, img_width))
y1 = max(0, min(y1, img_height))
x2 = max(x1, min(x2, img_width))
y2 = max(y1, min(y2, img_height))
# Skip if bounding box is too small or invalid
if x2 - x1 < 2 or y2 - y1 < 2:
return
# Get element color based on type
tag_name = element.tag_name if hasattr(element, 'tag_name') else 'div'
element_type = None
if hasattr(element, 'attributes') and element.attributes:
element_type = element.attributes.get('type')
color = get_element_color(tag_name, element_type)
# Get element index for overlay and apply filtering
element_index = getattr(element, 'element_index', None)
index_text = None
if element_index is not None:
if filter_highlight_ids:
# Use the meaningful text that matches what the LLM sees
meaningful_text = element.get_meaningful_text_for_llm()
# Show ID only if meaningful text is less than 5 characters
if len(meaningful_text) < 3:
index_text = str(element_index)
else:
# Always show ID when filter is disabled
index_text = str(element_index)
# Draw enhanced bounding box with bigger index
draw_enhanced_bounding_box_with_text(
draw, (x1, y1, x2, y2), color, index_text, font, tag_name, image_size, device_pixel_ratio
)
except Exception as e:
logger.debug(f'Failed to draw highlight for element {element_id}: {e}')
@observe_debug(ignore_input=True, ignore_output=True, name='create_highlighted_screenshot')
@time_execution_async('create_highlighted_screenshot')
async def create_highlighted_screenshot(
screenshot_b64: str,
selector_map: DOMSelectorMap,
device_pixel_ratio: float = 1.0,
viewport_offset_x: int = 0,
viewport_offset_y: int = 0,
filter_highlight_ids: bool = True,
) -> str:
"""Create a highlighted screenshot with bounding boxes around interactive elements.
Args:
screenshot_b64: Base64 encoded screenshot
selector_map: Map of interactive elements with their positions
device_pixel_ratio: Device pixel ratio for scaling coordinates
viewport_offset_x: X offset for viewport positioning
viewport_offset_y: Y offset for viewport positioning
Returns:
Base64 encoded highlighted screenshot
"""
try:
# Decode screenshot
screenshot_data = base64.b64decode(screenshot_b64)
image = Image.open(io.BytesIO(screenshot_data)).convert('RGBA')
# Create drawing context
draw = ImageDraw.Draw(image)
# Load font using shared function with caching
font = get_cross_platform_font(12)
# If no system fonts found, font remains None and will use default font
# Process elements sequentially to avoid ImageDraw thread safety issues
# PIL ImageDraw is not thread-safe, so we process elements one by one
for element_id, element in selector_map.items():
process_element_highlight(element_id, element, draw, device_pixel_ratio, font, filter_highlight_ids, image.size)
# Convert back to base64
output_buffer = io.BytesIO()
try:
image.save(output_buffer, format='PNG')
output_buffer.seek(0)
highlighted_b64 = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
logger.debug(f'Successfully created highlighted screenshot with {len(selector_map)} elements')
return highlighted_b64
finally:
# Explicit cleanup to prevent memory leaks
output_buffer.close()
if 'image' in locals():
image.close()
except Exception as e:
logger.error(f'Failed to create highlighted screenshot: {e}')
# Clean up on error as well
if 'image' in locals():
image.close()
# Return original screenshot on error
return screenshot_b64
async def get_viewport_info_from_cdp(cdp_session) -> tuple[float, int, int]:
"""Get viewport information from CDP session.
Returns:
Tuple of (device_pixel_ratio, scroll_x, scroll_y)
"""
try:
# Get layout metrics which includes viewport info and device pixel ratio
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
# Extract viewport information
visual_viewport = metrics.get('visualViewport', {})
css_visual_viewport = metrics.get('cssVisualViewport', {})
css_layout_viewport = metrics.get('cssLayoutViewport', {})
# Calculate device pixel ratio
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
device_width = visual_viewport.get('clientWidth', css_width)
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
# Get scroll position in CSS pixels
scroll_x = int(css_visual_viewport.get('pageX', 0))
scroll_y = int(css_visual_viewport.get('pageY', 0))
return float(device_pixel_ratio), scroll_x, scroll_y
except Exception as e:
logger.debug(f'Failed to get viewport info from CDP: {e}')
return 1.0, 0, 0
@time_execution_async('create_highlighted_screenshot_async')
async def create_highlighted_screenshot_async(
screenshot_b64: str, selector_map: DOMSelectorMap, cdp_session=None, filter_highlight_ids: bool = True
) -> str:
"""Async wrapper for creating highlighted screenshots.
Args:
screenshot_b64: Base64 encoded screenshot
selector_map: Map of interactive elements
cdp_session: CDP session for getting viewport info
filter_highlight_ids: Whether to filter element IDs based on meaningful text
Returns:
Base64 encoded highlighted screenshot
"""
# Get viewport information if CDP session is available
device_pixel_ratio = 1.0
viewport_offset_x = 0
viewport_offset_y = 0
if cdp_session:
try:
device_pixel_ratio, viewport_offset_x, viewport_offset_y = await get_viewport_info_from_cdp(cdp_session)
except Exception as e:
logger.debug(f'Failed to get viewport info from CDP: {e}')
# Create highlighted screenshot with async processing
final_screenshot = await create_highlighted_screenshot(
screenshot_b64, selector_map, device_pixel_ratio, viewport_offset_x, viewport_offset_y, filter_highlight_ids
)
filename = os.getenv('BROWSER_USE_SCREENSHOT_FILE')
if filename:
def _write_screenshot():
try:
with open(filename, 'wb') as f:
f.write(base64.b64decode(final_screenshot))
logger.debug('Saved screenshot to ' + str(filename))
except Exception as e:
logger.warning(f'Failed to save screenshot to {filename}: {e}')
await asyncio.to_thread(_write_screenshot)
return final_screenshot
# Export the cleanup function for external use in long-running applications
__all__ = ['create_highlighted_screenshot', 'create_highlighted_screenshot_async', 'cleanup_font_cache']
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
"""Video Recording Service for Browser Use Sessions."""
import base64
import logging
import math
import subprocess
from pathlib import Path
from typing import Optional
from browser_use.browser.profile import ViewportSize
try:
import imageio.v2 as iio # type: ignore[import-not-found]
import imageio_ffmpeg # type: ignore[import-not-found]
import numpy as np # type: ignore[import-not-found]
from imageio.core.format import Format # type: ignore[import-not-found]
IMAGEIO_AVAILABLE = True
except ImportError:
IMAGEIO_AVAILABLE = False
logger = logging.getLogger(__name__)
def _get_padded_size(size: ViewportSize, macro_block_size: int = 16) -> ViewportSize:
"""Calculates the dimensions padded to the nearest multiple of macro_block_size."""
width = int(math.ceil(size['width'] / macro_block_size)) * macro_block_size
height = int(math.ceil(size['height'] / macro_block_size)) * macro_block_size
return ViewportSize(width=width, height=height)
class VideoRecorderService:
"""
Handles the video encoding process for a browser session using imageio.
This service captures individual frames from the CDP screencast, decodes them,
and appends them to a video file using a pip-installable ffmpeg backend.
It automatically resizes frames to match the target video dimensions.
"""
def __init__(self, output_path: Path, size: ViewportSize, framerate: int):
"""
Initializes the video recorder.
Args:
output_path: The full path where the video will be saved.
size: A ViewportSize object specifying the width and height of the video.
framerate: The desired framerate for the output video.
"""
self.output_path = output_path
self.size = size
self.framerate = framerate
self._writer: Optional['Format.Writer'] = None
self._is_active = False
self.padded_size = _get_padded_size(self.size)
def start(self) -> None:
"""
Prepares and starts the video writer.
If the required optional dependencies are not installed, this method will
log an error and do nothing.
"""
if not IMAGEIO_AVAILABLE:
logger.error(
'MP4 recording requires optional dependencies. Please install them with: pip install "browser-use[video]"'
)
return
try:
self.output_path.parent.mkdir(parents=True, exist_ok=True)
# The macro_block_size is set to None because we handle padding ourselves
self._writer = iio.get_writer(
str(self.output_path),
fps=self.framerate,
codec='libx264',
quality=8, # A good balance of quality and file size (1-10 scale)
pixelformat='yuv420p', # Ensures compatibility with most players
macro_block_size=None,
)
self._is_active = True
logger.debug(f'Video recorder started. Output will be saved to {self.output_path}')
except Exception as e:
logger.error(f'Failed to initialize video writer: {e}')
self._is_active = False
def add_frame(self, frame_data_b64: str) -> None:
"""
Decodes a base64-encoded PNG frame, resizes it, pads it to be codec-compatible,
and appends it to the video.
Args:
frame_data_b64: A base64-encoded string of the PNG frame data.
"""
if not self._is_active or not self._writer:
return
try:
frame_bytes = base64.b64decode(frame_data_b64)
# Build a filter chain for ffmpeg:
# 1. scale: Resizes the frame to the user-specified dimensions.
# 2. pad: Adds black bars to meet codec's macro-block requirements,
# centering the original content.
vf_chain = (
f'scale={self.size["width"]}:{self.size["height"]},'
f'pad={self.padded_size["width"]}:{self.padded_size["height"]}:(ow-iw)/2:(oh-ih)/2:color=black'
)
output_pix_fmt = 'rgb24'
command = [
imageio_ffmpeg.get_ffmpeg_exe(),
'-f',
'image2pipe', # Input format from a pipe
'-c:v',
'png', # Specify input codec is PNG
'-i',
'-', # Input from stdin
'-vf',
vf_chain, # Video filter for resizing and padding
'-f',
'rawvideo', # Output format is raw video
'-pix_fmt',
output_pix_fmt, # Output pixel format
'-', # Output to stdout
]
# Execute ffmpeg as a subprocess
proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate(input=frame_bytes)
if proc.returncode != 0:
err_msg = err.decode(errors='ignore').strip()
if 'deprecated pixel format used' not in err_msg.lower():
raise OSError(f'ffmpeg error during resizing/padding: {err_msg}')
else:
logger.debug(f'ffmpeg warning during resizing/padding: {err_msg}')
# Convert the raw output bytes to a numpy array with the padded dimensions
img_array = np.frombuffer(out, dtype=np.uint8).reshape((self.padded_size['height'], self.padded_size['width'], 3))
self._writer.append_data(img_array)
except Exception as e:
logger.warning(f'Could not process and add video frame: {e}')
def stop_and_save(self) -> None:
"""
Finalizes the video file by closing the writer.
This method should be called when the recording session is complete.
"""
if not self._is_active or not self._writer:
return
try:
self._writer.close()
logger.info(f'📹 Video recording saved successfully to: {self.output_path}')
except Exception as e:
logger.error(f'Failed to finalize and save video: {e}')
finally:
self._is_active = False
self._writer = None
@@ -0,0 +1,176 @@
from dataclasses import dataclass, field
from typing import Any
from bubus import BaseEvent
from cdp_use.cdp.target import TargetID
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_serializer
from browser_use.dom.views import DOMInteractedElement, SerializedDOMState
# Known placeholder image data for about:blank pages - a 4x4 white PNG
PLACEHOLDER_4PX_SCREENSHOT = (
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGP8//8/AwwwMSAB3BwAlm4DBfIlvvkAAAAASUVORK5CYII='
)
# Pydantic
class TabInfo(BaseModel):
"""Represents information about a browser tab"""
model_config = ConfigDict(
extra='forbid',
validate_by_name=True,
validate_by_alias=True,
populate_by_name=True,
)
# Original fields
url: str
title: str
target_id: TargetID = Field(serialization_alias='tab_id', validation_alias=AliasChoices('tab_id', 'target_id'))
parent_target_id: TargetID | None = Field(
default=None, serialization_alias='parent_tab_id', validation_alias=AliasChoices('parent_tab_id', 'parent_target_id')
) # parent page that contains this popup or cross-origin iframe
@field_serializer('target_id')
def serialize_target_id(self, target_id: TargetID, _info: Any) -> str:
return target_id[-4:]
@field_serializer('parent_target_id')
def serialize_parent_target_id(self, parent_target_id: TargetID | None, _info: Any) -> str | None:
return parent_target_id[-4:] if parent_target_id else None
class PageInfo(BaseModel):
"""Comprehensive page size and scroll information"""
# Current viewport dimensions
viewport_width: int
viewport_height: int
# Total page dimensions
page_width: int
page_height: int
# Current scroll position
scroll_x: int
scroll_y: int
# Calculated scroll information
pixels_above: int
pixels_below: int
pixels_left: int
pixels_right: int
# Page statistics are now computed dynamically instead of stored
@dataclass
class BrowserStateSummary:
"""The summary of the browser's current state designed for an LLM to process"""
# provided by SerializedDOMState:
dom_state: SerializedDOMState
url: str
title: str
tabs: list[TabInfo]
screenshot: str | None = field(default=None, repr=False)
page_info: PageInfo | None = None # Enhanced page information
# Keep legacy fields for backward compatibility
pixels_above: int = 0
pixels_below: int = 0
browser_errors: list[str] = field(default_factory=list)
is_pdf_viewer: bool = False # Whether the current page is a PDF viewer
recent_events: str | None = None # Text summary of recent browser events
@dataclass
class BrowserStateHistory:
"""The summary of the browser's state at a past point in time to usse in LLM message history"""
url: str
title: str
tabs: list[TabInfo]
interacted_element: list[DOMInteractedElement | None] | list[None]
screenshot_path: str | None = None
def get_screenshot(self) -> str | None:
"""Load screenshot from disk and return as base64 string"""
if not self.screenshot_path:
return None
import base64
from pathlib import Path
path_obj = Path(self.screenshot_path)
if not path_obj.exists():
return None
try:
with open(path_obj, 'rb') as f:
screenshot_data = f.read()
return base64.b64encode(screenshot_data).decode('utf-8')
except Exception:
return None
def to_dict(self) -> dict[str, Any]:
data = {}
data['tabs'] = [tab.model_dump() for tab in self.tabs]
data['screenshot_path'] = self.screenshot_path
data['interacted_element'] = [el.to_dict() if el else None for el in self.interacted_element]
data['url'] = self.url
data['title'] = self.title
return data
class BrowserError(Exception):
"""Browser error with structured memory for LLM context management.
This exception class provides separate memory contexts for browser actions:
- short_term_memory: Immediate context shown once to the LLM for the next action
- long_term_memory: Persistent error information stored across steps
"""
message: str
short_term_memory: str | None = None
long_term_memory: str | None = None
details: dict[str, Any] | None = None
while_handling_event: BaseEvent[Any] | None = None
def __init__(
self,
message: str,
short_term_memory: str | None = None,
long_term_memory: str | None = None,
details: dict[str, Any] | None = None,
event: BaseEvent[Any] | None = None,
):
"""Initialize a BrowserError with structured memory contexts.
Args:
message: Technical error message for logging and debugging
short_term_memory: Context shown once to LLM (e.g., available actions, options)
long_term_memory: Persistent error info stored in agent memory
details: Additional metadata for debugging
event: The browser event that triggered this error
"""
self.message = message
self.short_term_memory = short_term_memory
self.long_term_memory = long_term_memory
self.details = details
self.while_handling_event = event
super().__init__(message)
def __str__(self) -> str:
if self.details:
return f'{self.message} ({self.details}) during: {self.while_handling_event}'
elif self.while_handling_event:
return f'{self.message} (while handling: {self.while_handling_event})'
else:
return self.message
class URLNotAllowedError(BrowserError):
"""Error raised when a URL is not allowed"""
@@ -0,0 +1,268 @@
"""Base watchdog class for browser monitoring components."""
import inspect
import time
from collections.abc import Iterable
from typing import Any, ClassVar
from bubus import BaseEvent, EventBus
from pydantic import BaseModel, ConfigDict, Field
from browser_use.browser.session import BrowserSession
class BaseWatchdog(BaseModel):
"""Base class for all browser watchdogs.
Watchdogs monitor browser state and emit events based on changes.
They automatically register event handlers based on method names.
Handler methods should be named: on_EventTypeName(self, event: EventTypeName)
"""
model_config = ConfigDict(
arbitrary_types_allowed=True, # allow non-serializable objects like EventBus/BrowserSession in fields
extra='forbid', # dont allow implicit class/instance state, everything must be a properly typed Field or PrivateAttr
validate_assignment=False, # avoid re-triggering __init__ / validators on values on every assignment
revalidate_instances='never', # avoid re-triggering __init__ / validators and erasing private attrs
)
# Class variables to statically define the list of events relevant to each watchdog
# (not enforced, just to make it easier to understand the code and debug watchdogs at runtime)
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog listens to
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog emits
# Core dependencies
event_bus: EventBus = Field()
browser_session: BrowserSession = Field()
# Shared state that other watchdogs might need to access should not be defined on BrowserSession, not here!
# Shared helper methods needed by other watchdogs should be defined on BrowserSession, not here!
# Alternatively, expose some events on the watchdog to allow access to state/helpers via event_bus system.
# Private state internal to the watchdog can be defined like this on BaseWatchdog subclasses:
# _screenshot_cache: dict[str, bytes] = PrivateAttr(default_factory=dict)
# _browser_crash_watcher_task: asyncio.Task | None = PrivateAttr(default=None)
# _cdp_download_tasks: WeakSet[asyncio.Task] = PrivateAttr(default_factory=WeakSet)
# ...
@property
def logger(self):
"""Get the logger from the browser session."""
return self.browser_session.logger
@staticmethod
def attach_handler_to_session(browser_session: 'BrowserSession', event_class: type[BaseEvent[Any]], handler) -> None:
"""Attach a single event handler to a browser session.
Args:
browser_session: The browser session to attach to
event_class: The event class to listen for
handler: The handler method (must start with 'on_' and end with event type)
"""
event_bus = browser_session.event_bus
# Validate handler naming convention
assert hasattr(handler, '__name__'), 'Handler must have a __name__ attribute'
assert handler.__name__.startswith('on_'), f'Handler {handler.__name__} must start with "on_"'
assert handler.__name__.endswith(event_class.__name__), (
f'Handler {handler.__name__} must end with event type {event_class.__name__}'
)
# Get the watchdog instance if this is a bound method
watchdog_instance = getattr(handler, '__self__', None)
watchdog_class_name = watchdog_instance.__class__.__name__ if watchdog_instance else 'Unknown'
# Color codes for logging
red = '\033[91m'
green = '\033[92m'
yellow = '\033[93m'
magenta = '\033[95m'
cyan = '\033[96m'
reset = '\033[0m'
# Create a wrapper function with unique name to avoid duplicate handler warnings
# Capture handler by value to avoid closure issues
def make_unique_handler(actual_handler):
async def unique_handler(event):
# just for debug logging, not used for anything else
parent_event = event_bus.event_history.get(event.event_parent_id) if event.event_parent_id else None
grandparent_event = (
event_bus.event_history.get(parent_event.event_parent_id)
if parent_event and parent_event.event_parent_id
else None
)
parent = (
f'{yellow}↲ triggered by {cyan}on_{parent_event.event_type}#{parent_event.event_id[-4:]}{reset}'
if parent_event
else f'{magenta}👈 by Agent{reset}'
)
grandparent = (
(
f'{yellow}↲ under {cyan}{grandparent_event.event_type}#{grandparent_event.event_id[-4:]}{reset}'
if grandparent_event
else f'{magenta}👈 by Agent{reset}'
)
if parent_event
else ''
)
event_str = f'#{event.event_id[-4:]}'
time_start = time.time()
watchdog_and_handler_str = f'[{watchdog_class_name}.{actual_handler.__name__}({event_str})]'.ljust(54)
browser_session.logger.debug(
f'{cyan}🚌 {watchdog_and_handler_str} ⏳ Starting... {reset} {parent} {grandparent}'
)
try:
# **EXECUTE THE EVENT HANDLER FUNCTION**
result = await actual_handler(event)
if isinstance(result, Exception):
raise result
# just for debug logging, not used for anything else
time_end = time.time()
time_elapsed = time_end - time_start
result_summary = '' if result is None else f' ➡️ {magenta}<{type(result).__name__}>{reset}'
parents_summary = f' {parent}'.replace('↲ triggered by ', f'{green}returned to {cyan}').replace(
'👈 by Agent', f'👉 {green}returned to {magenta}Agent{reset}'
)
browser_session.logger.debug(
f'{green}🚌 {watchdog_and_handler_str} ✅ Succeeded ({time_elapsed:.2f}s){reset}{result_summary}{parents_summary}'
)
return result
except Exception as e:
time_end = time.time()
time_elapsed = time_end - time_start
original_error = e
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ Failed ({time_elapsed:.2f}s): {type(e).__name__}: {e}{reset}'
)
# attempt to repair potentially crashed CDP session
try:
if browser_session.agent_focus and browser_session.agent_focus.target_id:
# Common issue with CDP, some calls need the target to be active/foreground to succeed:
# screenshot, scroll, Page.handleJavaScriptDialog, and some others
browser_session.logger.debug(
f'{yellow}🚌 {watchdog_and_handler_str} ⚠️ Re-foregrounding target to try and recover crashed CDP session\n\t{browser_session.agent_focus}{reset}'
)
del browser_session._cdp_session_pool[browser_session.agent_focus.target_id]
browser_session.agent_focus = await browser_session.get_or_create_cdp_session(
target_id=browser_session.agent_focus.target_id, new_socket=True
)
await browser_session.agent_focus.cdp_client.send.Target.activateTarget(
params={'targetId': browser_session.agent_focus.target_id}
)
else:
await browser_session.get_or_create_cdp_session(target_id=None, new_socket=True, focus=True)
except Exception as sub_error:
if 'ConnectionClosedError' in str(type(sub_error)) or 'ConnectionError' in str(type(sub_error)):
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ Browser closed or CDP Connection disconnected by remote. {red}{type(sub_error).__name__}: {sub_error}{reset}\n'
)
raise
else:
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ CDP connected but failed to re-create CDP session after error "{type(original_error).__name__}: {original_error}" in {cyan}{actual_handler.__name__}({event.event_type}#{event.event_id[-4:]}){reset}: due to {red}{type(sub_error).__name__}: {sub_error}{reset}\n'
)
raise
return unique_handler
unique_handler = make_unique_handler(handler)
unique_handler.__name__ = f'{watchdog_class_name}.{handler.__name__}'
# Check if this handler is already registered - throw error if duplicate
existing_handlers = event_bus.handlers.get(event_class.__name__, [])
handler_names = [getattr(h, '__name__', str(h)) for h in existing_handlers]
if unique_handler.__name__ in handler_names:
raise RuntimeError(
f'[{watchdog_class_name}] Duplicate handler registration attempted! '
f'Handler {unique_handler.__name__} is already registered for {event_class.__name__}. '
f'This likely means attach_to_session() was called multiple times.'
)
event_bus.on(event_class, unique_handler)
def attach_to_session(self) -> None:
"""Attach watchdog to its browser session and start monitoring.
This method handles event listener registration. The watchdog is already
bound to a browser session via self.browser_session from initialization.
"""
# Register event handlers automatically based on method names
assert self.browser_session is not None, 'Root CDP client not initialized - browser may not be connected yet'
from browser_use.browser import events
event_classes = {}
for name in dir(events):
obj = getattr(events, name)
if inspect.isclass(obj) and issubclass(obj, BaseEvent) and obj is not BaseEvent:
event_classes[name] = obj
# Find all handler methods (on_EventName)
registered_events = set()
for method_name in dir(self):
if method_name.startswith('on_') and callable(getattr(self, method_name)):
# Extract event name from method name (on_EventName -> EventName)
event_name = method_name[3:] # Remove 'on_' prefix
if event_name in event_classes:
event_class = event_classes[event_name]
# ASSERTION: If LISTENS_TO is defined, enforce it
if self.LISTENS_TO:
assert event_class in self.LISTENS_TO, (
f'[{self.__class__.__name__}] Handler {method_name} listens to {event_name} '
f'but {event_name} is not declared in LISTENS_TO: {[e.__name__ for e in self.LISTENS_TO]}'
)
handler = getattr(self, method_name)
# Use the static helper to attach the handler
self.attach_handler_to_session(self.browser_session, event_class, handler)
registered_events.add(event_class)
# ASSERTION: If LISTENS_TO is defined, ensure all declared events have handlers
if self.LISTENS_TO:
missing_handlers = set(self.LISTENS_TO) - registered_events
if missing_handlers:
missing_names = [e.__name__ for e in missing_handlers]
self.logger.warning(
f'[{self.__class__.__name__}] LISTENS_TO declares {missing_names} '
f'but no handlers found (missing on_{"_, on_".join(missing_names)} methods)'
)
def __del__(self) -> None:
"""Clean up any running tasks during garbage collection."""
# A BIT OF MAGIC: Cancel any private attributes that look like asyncio tasks
try:
for attr_name in dir(self):
# e.g. _browser_crash_watcher_task = asyncio.Task
if attr_name.startswith('_') and attr_name.endswith('_task'):
try:
task = getattr(self, attr_name)
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
task.cancel()
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
except Exception:
pass # Ignore errors during cleanup
# e.g. _cdp_download_tasks = WeakSet[asyncio.Task] or list[asyncio.Task]
if attr_name.startswith('_') and attr_name.endswith('_tasks') and isinstance(getattr(self, attr_name), Iterable):
for task in getattr(self, attr_name):
try:
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
task.cancel()
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
except Exception:
pass # Ignore errors during cleanup
except Exception as e:
from browser_use.utils import logger
logger.error(f'⚠️ Error during BrowserSession {self.__class__.__name__} gargabe collection __del__(): {type(e)}: {e}')
@@ -0,0 +1,253 @@
"""About:blank watchdog for managing about:blank tabs with DVD screensaver."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.target import TargetID
from pydantic import PrivateAttr
from browser_use.browser.events import (
AboutBlankDVDScreensaverShownEvent,
BrowserStopEvent,
BrowserStoppedEvent,
CloseTabEvent,
NavigateToUrlEvent,
TabClosedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class AboutBlankWatchdog(BaseWatchdog):
"""Ensures there's always exactly one about:blank tab with DVD screensaver."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserStopEvent,
BrowserStoppedEvent,
TabCreatedEvent,
TabClosedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
NavigateToUrlEvent,
CloseTabEvent,
AboutBlankDVDScreensaverShownEvent,
]
_stopping: bool = PrivateAttr(default=False)
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Handle browser stop request - stop creating new tabs."""
# logger.info('[AboutBlankWatchdog] Browser stop requested, stopping tab creation')
self._stopping = True
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Handle browser stopped event."""
# logger.info('[AboutBlankWatchdog] Browser stopped')
self._stopping = True
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Check tabs when a new tab is created."""
# logger.debug(f'[AboutBlankWatchdog] New tab created: {event.url}')
# If an about:blank tab was created, show DVD screensaver on all about:blank tabs
if event.url == 'about:blank':
await self._show_dvd_screensaver_on_about_blank_tabs()
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
"""Check tabs when a tab is closed and proactively create about:blank if needed."""
# logger.debug('[AboutBlankWatchdog] Tab closing, checking if we need to create about:blank tab')
# Don't create new tabs if browser is shutting down
if self._stopping:
# logger.debug('[AboutBlankWatchdog] Browser is stopping, not creating new tabs')
return
# Check if we're about to close the last tab (event happens BEFORE tab closes)
# Use _cdp_get_all_pages for quick check without fetching titles
page_targets = await self.browser_session._cdp_get_all_pages()
if len(page_targets) <= 1:
self.logger.debug(
'[AboutBlankWatchdog] Last tab closing, creating new about:blank tab to avoid closing entire browser'
)
# Create the animation tab since no tabs should remain
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
await navigate_event
# Show DVD screensaver on the new tab
await self._show_dvd_screensaver_on_about_blank_tabs()
else:
# Multiple tabs exist, check after close
await self._check_and_ensure_about_blank_tab()
async def attach_to_target(self, target_id: TargetID) -> None:
"""AboutBlankWatchdog doesn't monitor individual targets."""
pass
async def _check_and_ensure_about_blank_tab(self) -> None:
"""Check current tabs and ensure exactly one about:blank tab with animation exists."""
try:
# For quick checks, just get page targets without titles to reduce noise
page_targets = await self.browser_session._cdp_get_all_pages()
# If no tabs exist at all, create one to keep browser alive
if len(page_targets) == 0:
# Only create a new tab if there are no tabs at all
self.logger.debug('[AboutBlankWatchdog] No tabs exist, creating new about:blank DVD screensaver tab')
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
await navigate_event
# Show DVD screensaver on the new tab
await self._show_dvd_screensaver_on_about_blank_tabs()
# Otherwise there are tabs, don't create new ones to avoid interfering
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error ensuring about:blank tab: {e}')
async def _show_dvd_screensaver_on_about_blank_tabs(self) -> None:
"""Show DVD screensaver on all about:blank pages only."""
try:
# Get just the page targets without expensive title fetching
page_targets = await self.browser_session._cdp_get_all_pages()
browser_session_label = str(self.browser_session.id)[-4:]
for page_target in page_targets:
target_id = page_target['targetId']
url = page_target['url']
# Only target about:blank pages specifically
if url == 'about:blank':
await self._show_dvd_screensaver_loading_animation_cdp(target_id, browser_session_label)
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error showing DVD screensaver: {e}')
async def _show_dvd_screensaver_loading_animation_cdp(self, target_id: TargetID, browser_session_label: str) -> None:
"""
Injects a DVD screensaver-style bouncing logo loading animation overlay into the target using CDP.
This is used to visually indicate that the browser is setting up or waiting.
"""
try:
# Create temporary session for this target without switching focus
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Inject the DVD screensaver script (from main branch with idempotency added)
script = f"""
(function(browser_session_label) {{
// Idempotency check
if (window.__dvdAnimationRunning) {{
return; // Already running, don't add another
}}
window.__dvdAnimationRunning = true;
// Ensure document.body exists before proceeding
if (!document.body) {{
// Try again after DOM is ready
window.__dvdAnimationRunning = false; // Reset flag to retry
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', () => arguments.callee(browser_session_label));
}}
return;
}}
const animated_title = `Starting agent ${{browser_session_label}}...`;
if (document.title === animated_title) {{
return; // already run on this tab, dont run again
}}
document.title = animated_title;
// Create the main overlay
const loadingOverlay = document.createElement('div');
loadingOverlay.id = 'pretty-loading-animation';
loadingOverlay.style.position = 'fixed';
loadingOverlay.style.top = '0';
loadingOverlay.style.left = '0';
loadingOverlay.style.width = '100vw';
loadingOverlay.style.height = '100vh';
loadingOverlay.style.background = '#000';
loadingOverlay.style.zIndex = '99999';
loadingOverlay.style.overflow = 'hidden';
// Create the image element
const img = document.createElement('img');
img.src = 'https://cf.browser-use.com/logo.svg';
img.alt = 'Browser-Use';
img.style.width = '200px';
img.style.height = 'auto';
img.style.position = 'absolute';
img.style.left = '0px';
img.style.top = '0px';
img.style.zIndex = '2';
img.style.opacity = '0.8';
loadingOverlay.appendChild(img);
document.body.appendChild(loadingOverlay);
// DVD screensaver bounce logic
let x = Math.random() * (window.innerWidth - 300);
let y = Math.random() * (window.innerHeight - 300);
let dx = 1.2 + Math.random() * 0.4; // px per frame
let dy = 1.2 + Math.random() * 0.4;
// Randomize direction
if (Math.random() > 0.5) dx = -dx;
if (Math.random() > 0.5) dy = -dy;
function animate() {{
const imgWidth = img.offsetWidth || 300;
const imgHeight = img.offsetHeight || 300;
x += dx;
y += dy;
if (x <= 0) {{
x = 0;
dx = Math.abs(dx);
}} else if (x + imgWidth >= window.innerWidth) {{
x = window.innerWidth - imgWidth;
dx = -Math.abs(dx);
}}
if (y <= 0) {{
y = 0;
dy = Math.abs(dy);
}} else if (y + imgHeight >= window.innerHeight) {{
y = window.innerHeight - imgHeight;
dy = -Math.abs(dy);
}}
img.style.left = `${{x}}px`;
img.style.top = `${{y}}px`;
requestAnimationFrame(animate);
}}
animate();
// Responsive: update bounds on resize
window.addEventListener('resize', () => {{
x = Math.min(x, window.innerWidth - img.offsetWidth);
y = Math.min(y, window.innerHeight - img.offsetHeight);
}});
// Add a little CSS for smoothness
const style = document.createElement('style');
style.textContent = `
#pretty-loading-animation {{
/*backdrop-filter: blur(2px) brightness(0.9);*/
}}
#pretty-loading-animation img {{
user-select: none;
pointer-events: none;
}}
`;
document.head.appendChild(style);
}})('{browser_session_label}');
"""
await temp_session.cdp_client.send.Runtime.evaluate(params={'expression': script}, session_id=temp_session.session_id)
# No need to detach - session is cached
# Dispatch event
self.event_bus.dispatch(AboutBlankDVDScreensaverShownEvent(target_id=target_id))
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error injecting DVD screensaver: {e}')
@@ -0,0 +1,362 @@
"""Browser watchdog for monitoring crashes and network timeouts using CDP."""
import asyncio
import time
from typing import TYPE_CHECKING, ClassVar
import psutil
from bubus import BaseEvent
from cdp_use.cdp.target import SessionID, TargetID
from cdp_use.cdp.target.events import TargetCrashedEvent
from pydantic import Field, PrivateAttr
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserErrorEvent,
BrowserStoppedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class NetworkRequestTracker:
"""Tracks ongoing network requests."""
def __init__(self, request_id: str, start_time: float, url: str, method: str, resource_type: str | None = None):
self.request_id = request_id
self.start_time = start_time
self.url = url
self.method = method
self.resource_type = resource_type
class CrashWatchdog(BaseWatchdog):
"""Monitors browser health for crashes and network timeouts using CDP."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
BrowserStoppedEvent,
TabCreatedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [BrowserErrorEvent]
# Configuration
network_timeout_seconds: float = Field(default=10.0)
check_interval_seconds: float = Field(default=5.0) # Reduced frequency to reduce noise
# Private state
_active_requests: dict[str, NetworkRequestTracker] = PrivateAttr(default_factory=dict)
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
_last_responsive_checks: dict[str, float] = PrivateAttr(default_factory=dict) # target_url -> timestamp
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions that already have event listeners
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Start monitoring when browser is connected."""
# logger.debug('[CrashWatchdog] Browser connected event received, beginning monitoring')
asyncio.create_task(self._start_monitoring())
# logger.debug(f'[CrashWatchdog] Monitoring task started: {self._monitoring_task and not self._monitoring_task.done()}')
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Stop monitoring when browser stops."""
# logger.debug('[CrashWatchdog] Browser stopped, ending monitoring')
await self._stop_monitoring()
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Attach to new tab."""
assert self.browser_session.agent_focus is not None, 'No current target ID'
await self.attach_to_target(self.browser_session.agent_focus.target_id)
async def attach_to_target(self, target_id: TargetID) -> None:
"""Set up crash monitoring for a specific target using CDP."""
try:
# Create temporary session for monitoring without switching focus
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Check if we already have listeners for this session
if cdp_session.session_id in self._sessions_with_listeners:
self.logger.debug(f'[CrashWatchdog] Event listeners already exist for session: {cdp_session.session_id}')
return
# Set up network event handlers
# def on_request_will_be_sent(event):
# # Create and track the task
# task = asyncio.create_task(self._on_request_cdp(event))
# self._cdp_event_tasks.add(task)
# # Remove from set when done
# task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
# def on_response_received(event):
# self._on_response_cdp(event)
# def on_loading_failed(event):
# self._on_request_failed_cdp(event)
# def on_loading_finished(event):
# self._on_request_finished_cdp(event)
# Register event handlers
# TEMPORARILY DISABLED: Network events causing too much logging
# cdp_client.on('Network.requestWillBeSent', on_request_will_be_sent, session_id=session_id)
# cdp_client.on('Network.responseReceived', on_response_received, session_id=session_id)
# cdp_client.on('Network.loadingFailed', on_loading_failed, session_id=session_id)
# cdp_client.on('Network.loadingFinished', on_loading_finished, session_id=session_id)
def on_target_crashed(event: TargetCrashedEvent, session_id: SessionID | None = None):
# Create and track the task
task = asyncio.create_task(self._on_target_crash_cdp(target_id))
self._cdp_event_tasks.add(task)
# Remove from set when done
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
cdp_session.cdp_client.register.Target.targetCrashed(on_target_crashed)
# Track that we've added listeners to this session
self._sessions_with_listeners.add(cdp_session.session_id)
# Get target info for logging
targets = await cdp_session.cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if target_info:
self.logger.debug(f'[CrashWatchdog] Added target to monitoring: {target_info.get("url", "unknown")}')
except Exception as e:
self.logger.warning(f'[CrashWatchdog] Failed to attach to target {target_id}: {e}')
async def _on_request_cdp(self, event: dict) -> None:
"""Track new network request from CDP event."""
request_id = event.get('requestId', '')
request = event.get('request', {})
self._active_requests[request_id] = NetworkRequestTracker(
request_id=request_id,
start_time=time.time(),
url=request.get('url', ''),
method=request.get('method', ''),
resource_type=event.get('type'),
)
# logger.debug(f'[CrashWatchdog] Tracking request: {request.get("method", "")} {request.get("url", "")[:50]}...')
def _on_response_cdp(self, event: dict) -> None:
"""Remove request from tracking on response."""
request_id = event.get('requestId', '')
if request_id in self._active_requests:
elapsed = time.time() - self._active_requests[request_id].start_time
response = event.get('response', {})
self.logger.debug(f'[CrashWatchdog] Request completed in {elapsed:.2f}s: {response.get("url", "")[:50]}...')
# Don't remove yet - wait for loadingFinished
def _on_request_failed_cdp(self, event: dict) -> None:
"""Remove request from tracking on failure."""
request_id = event.get('requestId', '')
if request_id in self._active_requests:
elapsed = time.time() - self._active_requests[request_id].start_time
self.logger.debug(
f'[CrashWatchdog] Request failed after {elapsed:.2f}s: {self._active_requests[request_id].url[:50]}...'
)
del self._active_requests[request_id]
def _on_request_finished_cdp(self, event: dict) -> None:
"""Remove request from tracking when loading is finished."""
request_id = event.get('requestId', '')
self._active_requests.pop(request_id, None)
async def _on_target_crash_cdp(self, target_id: TargetID) -> None:
"""Handle target crash detected via CDP."""
# Remove crashed session from pool
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
await session.disconnect()
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
# Get target info
cdp_client = self.browser_session.cdp_client
targets = await cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if (
target_info
and self.browser_session.agent_focus
and target_info['targetId'] == self.browser_session.agent_focus.target_id
):
self.browser_session.agent_focus.target_id = None # type: ignore
self.browser_session.agent_focus.session_id = None # type: ignore
self.logger.error(
f'[CrashWatchdog] 💥 Target crashed, navigating Agent to a new tab: {target_info.get("url", "unknown")}'
)
# Also emit generic browser error
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='TargetCrash',
message=f'Target crashed: {target_id}',
details={
# 'url': target_url, # TODO: add url to details
'target_id': target_id,
},
)
)
async def _start_monitoring(self) -> None:
"""Start the monitoring loop."""
assert self.browser_session.cdp_client is not None, 'Root CDP client not initialized - browser may not be connected yet'
if self._monitoring_task and not self._monitoring_task.done():
# logger.info('[CrashWatchdog] Monitoring already running')
return
self._monitoring_task = asyncio.create_task(self._monitoring_loop())
# logger.debug('[CrashWatchdog] Monitoring loop created and started')
async def _stop_monitoring(self) -> None:
"""Stop the monitoring loop."""
if self._monitoring_task and not self._monitoring_task.done():
self._monitoring_task.cancel()
try:
await self._monitoring_task
except asyncio.CancelledError:
pass
self.logger.debug('[CrashWatchdog] Monitoring loop stopped')
# Cancel all CDP event handler tasks
for task in list(self._cdp_event_tasks):
if not task.done():
task.cancel()
# Wait for all tasks to complete cancellation
if self._cdp_event_tasks:
await asyncio.gather(*self._cdp_event_tasks, return_exceptions=True)
self._cdp_event_tasks.clear()
# Clear tracking (CDP sessions are cached and managed by BrowserSession)
self._active_requests.clear()
self._sessions_with_listeners.clear()
async def _monitoring_loop(self) -> None:
"""Main monitoring loop."""
await asyncio.sleep(10) # give browser time to start up and load the first page after first LLM call
while True:
try:
await self._check_network_timeouts()
await self._check_browser_health()
await asyncio.sleep(self.check_interval_seconds)
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f'[CrashWatchdog] Error in monitoring loop: {e}')
async def _check_network_timeouts(self) -> None:
"""Check for network requests exceeding timeout."""
current_time = time.time()
timed_out_requests = []
# Debug logging
if self._active_requests:
self.logger.debug(
f'[CrashWatchdog] Checking {len(self._active_requests)} active requests for timeouts (threshold: {self.network_timeout_seconds}s)'
)
for request_id, tracker in self._active_requests.items():
elapsed = current_time - tracker.start_time
self.logger.debug(
f'[CrashWatchdog] Request {tracker.url[:30]}... elapsed: {elapsed:.1f}s, timeout: {self.network_timeout_seconds}s'
)
if elapsed >= self.network_timeout_seconds:
timed_out_requests.append((request_id, tracker))
# Emit events for timed out requests
for request_id, tracker in timed_out_requests:
self.logger.warning(
f'[CrashWatchdog] Network request timeout after {self.network_timeout_seconds}s: '
f'{tracker.method} {tracker.url[:100]}...'
)
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NetworkTimeout',
message=f'Network request timed out after {self.network_timeout_seconds}s',
details={
'url': tracker.url,
'method': tracker.method,
'resource_type': tracker.resource_type,
'elapsed_seconds': current_time - tracker.start_time,
},
)
)
# Remove from tracking
del self._active_requests[request_id]
async def _check_browser_health(self) -> None:
"""Check if browser and targets are still responsive."""
try:
try:
self.logger.debug(f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus}')
cdp_session = await self.browser_session.get_or_create_cdp_session()
except Exception as e:
self.logger.debug(
f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
)
self.agent_focus = cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id=self.agent_focus.target_id, new_socket=True, focus=True
)
for target in (await self.browser_session.cdp_client.send.Target.getTargets()).get('targetInfos', []):
if target.get('type') == 'page':
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target.get('targetId'))
if self._is_new_tab_page(target.get('url')) and target.get('url') != 'about:blank':
self.logger.debug(
f'[CrashWatchdog] Redirecting chrome://new-tab-page/ to about:blank {target.get("url")}'
)
await cdp_session.cdp_client.send.Page.navigate(
params={'url': 'about:blank'}, session_id=cdp_session.session_id
)
# Quick ping to check if session is alive
self.logger.debug(f'[CrashWatchdog] Attempting to run simple JS test expression in session {cdp_session} 1+1')
await asyncio.wait_for(
cdp_session.cdp_client.send.Runtime.evaluate(params={'expression': '1+1'}, session_id=cdp_session.session_id),
timeout=1.0,
)
self.logger.debug(f'[CrashWatchdog] Browser health check passed for target {self.browser_session.agent_focus}')
except Exception as e:
self.logger.error(
f'[CrashWatchdog] ❌ Crashed session detected for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
)
# Remove crashed session from pool
if self.browser_session.agent_focus and (target_id := self.browser_session.agent_focus.target_id):
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
await session.disconnect()
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
self.browser_session.agent_focus.target_id = None # type: ignore
# Check browser process if we have PID
if self.browser_session._local_browser_watchdog and (proc := self.browser_session._local_browser_watchdog._subprocess):
try:
if proc.status() in (psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD):
self.logger.error(f'[CrashWatchdog] Browser process {proc.pid} has crashed')
# Clear all sessions from pool when browser crashes
for session in self.browser_session._cdp_session_pool.values():
await session.disconnect()
self.browser_session._cdp_session_pool.clear()
self.logger.debug('[CrashWatchdog] Cleared all sessions from pool due to browser crash')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='BrowserProcessCrashed',
message=f'Browser process {proc.pid} has crashed',
details={'pid': proc.pid, 'status': proc.status()},
)
)
await self._stop_monitoring()
return
except Exception:
pass # psutil not available or process doesn't exist
@staticmethod
def _is_new_tab_page(url: str) -> bool:
"""Check if URL is a new tab page."""
return url in ['about:blank', 'chrome://new-tab-page/', 'chrome://newtab/']
@@ -0,0 +1,582 @@
"""DOM watchdog for browser DOM tree management using CDP."""
import asyncio
import time
from typing import TYPE_CHECKING
from browser_use.browser.events import (
BrowserErrorEvent,
BrowserStateRequestEvent,
ScreenshotEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.dom.service import DomService
from browser_use.dom.views import (
EnhancedDOMTreeNode,
SerializedDOMState,
)
from browser_use.observability import observe_debug
from browser_use.utils import time_execution_async
if TYPE_CHECKING:
from browser_use.browser.views import BrowserStateSummary, PageInfo
class DOMWatchdog(BaseWatchdog):
"""Handles DOM tree building, serialization, and element access via CDP.
This watchdog acts as a bridge between the event-driven browser session
and the DomService implementation, maintaining cached state and providing
helper methods for other watchdogs.
"""
LISTENS_TO = [TabCreatedEvent, BrowserStateRequestEvent]
EMITS = [BrowserErrorEvent]
# Public properties for other watchdogs
selector_map: dict[int, EnhancedDOMTreeNode] | None = None
current_dom_state: SerializedDOMState | None = None
enhanced_dom_tree: EnhancedDOMTreeNode | None = None
# Internal DOM service
_dom_service: DomService | None = None
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
# self.logger.debug('Setting up init scripts in browser')
return None
def _get_recent_events_str(self, limit: int = 10) -> str | None:
"""Get the most recent events from the event bus as JSON.
Args:
limit: Maximum number of recent events to include
Returns:
JSON string of recent events or None if not available
"""
import json
try:
# Get all events from history, sorted by creation time (most recent first)
all_events = sorted(
self.browser_session.event_bus.event_history.values(), key=lambda e: e.event_created_at.timestamp(), reverse=True
)
# Take the most recent events and create JSON-serializable data
recent_events_data = []
for event in all_events[:limit]:
event_data = {
'event_type': event.event_type,
'timestamp': event.event_created_at.isoformat(),
}
# Add specific fields for certain event types
if hasattr(event, 'url'):
event_data['url'] = getattr(event, 'url')
if hasattr(event, 'error_message'):
event_data['error_message'] = getattr(event, 'error_message')
if hasattr(event, 'target_id'):
event_data['target_id'] = getattr(event, 'target_id')
recent_events_data.append(event_data)
return json.dumps(recent_events_data) # Return empty array if no events
except Exception as e:
self.logger.debug(f'Failed to get recent events: {e}')
return json.dumps([]) # Return empty JSON array on error
@observe_debug(ignore_input=True, ignore_output=True, name='browser_state_request_event')
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> 'BrowserStateSummary':
"""Handle browser state request by coordinating DOM building and screenshot capture.
This is the main entry point for getting the complete browser state.
Args:
event: The browser state request event with options
Returns:
Complete BrowserStateSummary with DOM, screenshot, and target info
"""
from browser_use.browser.views import BrowserStateSummary, PageInfo
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: STARTING browser state request')
page_url = await self.browser_session.get_current_page_url()
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page URL: {page_url}')
if self.browser_session.agent_focus:
self.logger.debug(
f'Current page URL: {page_url}, target_id: {self.browser_session.agent_focus.target_id}, session_id: {self.browser_session.agent_focus.session_id}'
)
else:
self.logger.debug(f'Current page URL: {page_url}, no cdp_session attached')
# check if we should skip DOM tree build for pointless pages
not_a_meaningful_website = page_url.lower().split(':', 1)[0] not in ('http', 'https')
# Wait for page stability using browser profile settings (main branch pattern)
if not not_a_meaningful_website:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ⏳ Waiting for page stability...')
try:
await self._wait_for_stable_network()
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Page stability complete')
except Exception as e:
self.logger.warning(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Network waiting failed: {e}, continuing anyway...'
)
# Get tabs info once at the beginning for all paths
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting tabs info...')
tabs_info = await self.browser_session.get_tabs()
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got {len(tabs_info)} tabs')
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Tabs info: {tabs_info}')
# Get viewport / scroll position info, remember changing scroll position should invalidate selector_map cache because it only includes visible elements
# cdp_session = await self.browser_session.get_or_create_cdp_session(focus=True)
# scroll_info = await cdp_session.cdp_client.send.Runtime.evaluate(
# params={'expression': 'JSON.stringify({y: document.body.scrollTop, x: document.body.scrollLeft, width: document.documentElement.clientWidth, height: document.documentElement.clientHeight})'},
# session_id=cdp_session.session_id,
# )
# self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got scroll info: {scroll_info["result"]}')
try:
# Fast path for empty pages
if not_a_meaningful_website:
self.logger.debug(f'⚡ Skipping BuildDOMTree for empty target: {page_url}')
self.logger.debug(f'📸 Not taking screenshot for empty page: {page_url} (non-http/https URL)')
# Create minimal DOM state
content = SerializedDOMState(_root=None, selector_map={})
# Skip screenshot for empty pages
screenshot_b64 = None
# Try to get page info from CDP, fall back to defaults if unavailable
try:
page_info = await self._get_page_info()
except Exception as e:
self.logger.debug(f'Failed to get page info from CDP for empty page: {e}, using fallback')
# Use default viewport dimensions
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
page_info = PageInfo(
viewport_width=viewport['width'],
viewport_height=viewport['height'],
page_width=viewport['width'],
page_height=viewport['height'],
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
)
return BrowserStateSummary(
dom_state=content,
url=page_url,
title='Empty Tab',
tabs=tabs_info,
screenshot=screenshot_b64,
page_info=page_info,
pixels_above=0,
pixels_below=0,
browser_errors=[],
is_pdf_viewer=False,
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
)
# Execute DOM building and screenshot capture in parallel
dom_task = None
screenshot_task = None
# Start DOM building task if requested
if event.include_dom:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🌳 Starting DOM tree build task...')
previous_state = (
self.browser_session._cached_browser_state_summary.dom_state
if self.browser_session._cached_browser_state_summary
else None
)
dom_task = asyncio.create_task(self._build_dom_tree_without_highlights(previous_state))
# Start clean screenshot task if requested (without JS highlights)
if event.include_screenshot:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Starting clean screenshot task...')
screenshot_task = asyncio.create_task(self._capture_clean_screenshot())
# Wait for both tasks to complete
content = None
screenshot_b64 = None
if dom_task:
try:
content = await dom_task
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ DOM tree build completed')
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: DOM build failed: {e}, using minimal state')
content = SerializedDOMState(_root=None, selector_map={})
else:
content = SerializedDOMState(_root=None, selector_map={})
if screenshot_task:
try:
screenshot_b64 = await screenshot_task
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Clean screenshot captured')
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Clean screenshot failed: {e}')
screenshot_b64 = None
# Apply Python-based highlighting if both DOM and screenshot are available
if screenshot_b64 and content and content.selector_map and self.browser_session.browser_profile.highlight_elements:
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🎨 Applying Python-based highlighting...')
from browser_use.browser.python_highlights import create_highlighted_screenshot_async
# Get CDP session for viewport info
cdp_session = await self.browser_session.get_or_create_cdp_session()
start = time.time()
screenshot_b64 = await create_highlighted_screenshot_async(
screenshot_b64,
content.selector_map,
cdp_session,
self.browser_session.browser_profile.filter_highlight_ids,
)
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Applied highlights to {len(content.selector_map)} elements in {time.time() - start:.2f}s'
)
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Python highlighting failed: {e}')
# Ensure we have valid content
if not content:
content = SerializedDOMState(_root=None, selector_map={})
# Tabs info already fetched at the beginning
# Get target title safely
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page title...')
title = await asyncio.wait_for(self.browser_session.get_current_page_title(), timeout=1.0)
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got title: {title}')
except Exception as e:
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get title: {e}')
title = 'Page'
# Get comprehensive page info from CDP with timeout
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page info from CDP...')
page_info = await asyncio.wait_for(self._get_page_info(), timeout=1.0)
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page info from CDP: {page_info}')
except Exception as e:
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get page info from CDP: {e}, using fallback'
)
# Fallback to default viewport dimensions
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
page_info = PageInfo(
viewport_width=viewport['width'],
viewport_height=viewport['height'],
page_width=viewport['width'],
page_height=viewport['height'],
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
)
# Check for PDF viewer
is_pdf_viewer = page_url.endswith('.pdf') or '/pdf/' in page_url
# Build and cache the browser state summary
if screenshot_b64:
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary with screenshot, length: {len(screenshot_b64)}'
)
else:
self.logger.debug(
'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary WITHOUT screenshot'
)
browser_state = BrowserStateSummary(
dom_state=content,
url=page_url,
title=title,
tabs=tabs_info,
screenshot=screenshot_b64,
page_info=page_info,
pixels_above=0,
pixels_below=0,
browser_errors=[],
is_pdf_viewer=is_pdf_viewer,
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
)
# Cache the state
self.browser_session._cached_browser_state_summary = browser_state
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ COMPLETED - Returning browser state')
return browser_state
except Exception as e:
self.logger.error(f'Failed to get browser state: {e}')
# Return minimal recovery state
return BrowserStateSummary(
dom_state=SerializedDOMState(_root=None, selector_map={}),
url=page_url if 'page_url' in locals() else '',
title='Error',
tabs=[],
screenshot=None,
page_info=PageInfo(
viewport_width=1280,
viewport_height=720,
page_width=1280,
page_height=720,
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
),
pixels_above=0,
pixels_below=0,
browser_errors=[str(e)],
is_pdf_viewer=False,
recent_events=None,
)
@time_execution_async('build_dom_tree_without_highlights')
@observe_debug(ignore_input=True, ignore_output=True, name='build_dom_tree_without_highlights')
async def _build_dom_tree_without_highlights(self, previous_state: SerializedDOMState | None = None) -> SerializedDOMState:
"""Build DOM tree without injecting JavaScript highlights (for parallel execution)."""
try:
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: STARTING DOM tree build')
# Create or reuse DOM service
if self._dom_service is None:
self._dom_service = DomService(
browser_session=self.browser_session,
logger=self.logger,
cross_origin_iframes=self.browser_session.browser_profile.cross_origin_iframes,
paint_order_filtering=self.browser_session.browser_profile.paint_order_filtering,
max_iframes=self.browser_session.browser_profile.max_iframes,
max_iframe_depth=self.browser_session.browser_profile.max_iframe_depth,
)
# Get serialized DOM tree using the service
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Calling DomService.get_serialized_dom_tree...')
start = time.time()
self.current_dom_state, self.enhanced_dom_tree, timing_info = await self._dom_service.get_serialized_dom_tree(
previous_cached_state=previous_state,
)
end = time.time()
self.logger.debug(
'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ DomService.get_serialized_dom_tree completed'
)
self.logger.debug(f'Time taken to get DOM tree: {end - start} seconds')
self.logger.debug(f'Timing breakdown: {timing_info}')
# Update selector map for other watchdogs
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Updating selector maps...')
self.selector_map = self.current_dom_state.selector_map
# Update BrowserSession's cached selector map
if self.browser_session:
self.browser_session.update_cached_selector_map(self.selector_map)
self.logger.debug(
f'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ Selector maps updated, {len(self.selector_map)} elements'
)
# Skip JavaScript highlighting injection - Python highlighting will be applied later
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ COMPLETED DOM tree build (no JS highlights)')
return self.current_dom_state
except Exception as e:
self.logger.error(f'Failed to build DOM tree without highlights: {e}')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='DOMBuildFailed',
message=str(e),
)
)
raise
@time_execution_async('capture_clean_screenshot')
@observe_debug(ignore_input=True, ignore_output=True, name='capture_clean_screenshot')
async def _capture_clean_screenshot(self) -> str:
"""Capture a clean screenshot without JavaScript highlights."""
try:
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: Capturing clean screenshot...')
# Ensure we have a focused CDP session
assert self.browser_session.agent_focus is not None, 'No current target ID'
await self.browser_session.get_or_create_cdp_session(target_id=self.browser_session.agent_focus.target_id, focus=True)
# Check if handler is registered
handlers = self.event_bus.handlers.get('ScreenshotEvent', [])
handler_names = [getattr(h, '__name__', str(h)) for h in handlers]
self.logger.debug(f'📸 ScreenshotEvent handlers registered: {len(handlers)} - {handler_names}')
screenshot_event = self.event_bus.dispatch(ScreenshotEvent(full_page=False))
self.logger.debug('📸 Dispatched ScreenshotEvent, waiting for event to complete...')
# Wait for the event itself to complete (this waits for all handlers)
await screenshot_event
# Get the single handler result
screenshot_b64 = await screenshot_event.event_result(raise_if_any=True, raise_if_none=True)
if screenshot_b64 is None:
raise RuntimeError('Screenshot handler returned None')
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: ✅ Clean screenshot captured successfully')
return str(screenshot_b64)
except TimeoutError:
self.logger.warning('📸 Clean screenshot timed out after 6 seconds - no handler registered or slow page?')
raise
except Exception as e:
self.logger.warning(f'📸 Clean screenshot failed: {type(e).__name__}: {e}')
raise
async def _wait_for_stable_network(self):
"""Wait for page stability - simplified for CDP-only branch."""
start_time = time.time()
# Apply minimum wait time first (let page settle)
min_wait = self.browser_session.browser_profile.minimum_wait_page_load_time
if min_wait > 0:
self.logger.debug(f'⏳ Minimum wait: {min_wait}s')
await asyncio.sleep(min_wait)
# Apply network idle wait time (for dynamic content like iframes)
network_idle_wait = self.browser_session.browser_profile.wait_for_network_idle_page_load_time
if network_idle_wait > 0:
self.logger.debug(f'⏳ Network idle wait: {network_idle_wait}s')
await asyncio.sleep(network_idle_wait)
elapsed = time.time() - start_time
self.logger.debug(f'✅ Page stability wait completed in {elapsed:.2f}s')
async def _get_page_info(self) -> 'PageInfo':
"""Get comprehensive page information using a single CDP call.
TODO: should we make this an event as well?
Returns:
PageInfo with all viewport, page dimensions, and scroll information
"""
from browser_use.browser.views import PageInfo
# Get CDP session for the current target
if not self.browser_session.agent_focus:
raise RuntimeError('No active CDP session - browser may not be connected yet')
cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id=self.browser_session.agent_focus.target_id, focus=True
)
# Get layout metrics which includes all the information we need
metrics = await asyncio.wait_for(
cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id), timeout=10.0
)
# Extract different viewport types
layout_viewport = metrics.get('layoutViewport', {})
visual_viewport = metrics.get('visualViewport', {})
css_visual_viewport = metrics.get('cssVisualViewport', {})
css_layout_viewport = metrics.get('cssLayoutViewport', {})
content_size = metrics.get('contentSize', {})
# Calculate device pixel ratio to convert between device pixels and CSS pixels
# This matches the approach in dom/service.py _get_viewport_ratio method
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
device_width = visual_viewport.get('clientWidth', css_width)
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
# For viewport dimensions, use CSS pixels (what JavaScript sees)
# Prioritize CSS layout viewport, then fall back to layout viewport
viewport_width = int(css_layout_viewport.get('clientWidth') or layout_viewport.get('clientWidth', 1280))
viewport_height = int(css_layout_viewport.get('clientHeight') or layout_viewport.get('clientHeight', 720))
# For total page dimensions, content size is typically in device pixels, so convert to CSS pixels
# by dividing by device pixel ratio
raw_page_width = content_size.get('width', viewport_width * device_pixel_ratio)
raw_page_height = content_size.get('height', viewport_height * device_pixel_ratio)
page_width = int(raw_page_width / device_pixel_ratio)
page_height = int(raw_page_height / device_pixel_ratio)
# For scroll position, use CSS visual viewport if available, otherwise CSS layout viewport
# These should already be in CSS pixels
scroll_x = int(css_visual_viewport.get('pageX') or css_layout_viewport.get('pageX', 0))
scroll_y = int(css_visual_viewport.get('pageY') or css_layout_viewport.get('pageY', 0))
# Calculate scroll information - pixels that are above/below/left/right of current viewport
pixels_above = scroll_y
pixels_below = max(0, page_height - viewport_height - scroll_y)
pixels_left = scroll_x
pixels_right = max(0, page_width - viewport_width - scroll_x)
page_info = PageInfo(
viewport_width=viewport_width,
viewport_height=viewport_height,
page_width=page_width,
page_height=page_height,
scroll_x=scroll_x,
scroll_y=scroll_y,
pixels_above=pixels_above,
pixels_below=pixels_below,
pixels_left=pixels_left,
pixels_right=pixels_right,
)
return page_info
# ========== Public Helper Methods ==========
async def get_element_by_index(self, index: int) -> EnhancedDOMTreeNode | None:
"""Get DOM element by index from cached selector map.
Builds DOM if not cached.
Returns:
EnhancedDOMTreeNode or None if index not found
"""
if not self.selector_map:
# Build DOM if not cached
await self._build_dom_tree_without_highlights()
return self.selector_map.get(index) if self.selector_map else None
def clear_cache(self) -> None:
"""Clear cached DOM state to force rebuild on next access."""
self.selector_map = None
self.current_dom_state = None
self.enhanced_dom_tree = None
# Keep the DOM service instance to reuse its CDP client connection
def is_file_input(self, element: EnhancedDOMTreeNode) -> bool:
"""Check if element is a file input."""
return element.node_name.upper() == 'INPUT' and element.attributes.get('type', '').lower() == 'file'
@staticmethod
def is_element_visible_according_to_all_parents(node: EnhancedDOMTreeNode, html_frames: list[EnhancedDOMTreeNode]) -> bool:
"""Check if the element is visible according to all its parent HTML frames.
Delegates to the DomService static method.
"""
return DomService.is_element_visible_according_to_all_parents(node, html_frames)
async def __aexit__(self, exc_type, exc_value, traceback):
"""Clean up DOM service on exit."""
if self._dom_service:
await self._dom_service.__aexit__(exc_type, exc_value, traceback)
self._dom_service = None
def __del__(self):
"""Clean up DOM service on deletion."""
super().__del__()
# DOM service will clean up its own CDP client
self._dom_service = None
@@ -0,0 +1,933 @@
"""Downloads watchdog for monitoring and handling file downloads."""
import asyncio
import json
import os
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlparse
import anyio
from bubus import BaseEvent
from cdp_use.cdp.browser import DownloadProgressEvent, DownloadWillBeginEvent
from cdp_use.cdp.target import SessionID, TargetID
from pydantic import PrivateAttr
from browser_use.browser.events import (
BrowserLaunchEvent,
BrowserStateRequestEvent,
BrowserStoppedEvent,
FileDownloadedEvent,
NavigationCompleteEvent,
TabClosedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class DownloadsWatchdog(BaseWatchdog):
"""Monitors downloads and handles file download events."""
# Events this watchdog listens to (for documentation)
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
BrowserLaunchEvent,
BrowserStateRequestEvent,
BrowserStoppedEvent,
TabCreatedEvent,
TabClosedEvent,
NavigationCompleteEvent,
]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [
FileDownloadedEvent,
]
# Private state
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions that already have download listeners
_active_downloads: dict[str, Any] = PrivateAttr(default_factory=dict)
_pdf_viewer_cache: dict[str, bool] = PrivateAttr(default_factory=dict) # Cache PDF viewer status by target URL
_download_cdp_session_setup: bool = PrivateAttr(default=False) # Track if CDP session is set up
_download_cdp_session: Any = PrivateAttr(default=None) # Store CDP session reference
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
_cdp_downloads_info: dict[str, dict[str, Any]] = PrivateAttr(default_factory=dict) # Map guid -> info
_use_js_fetch_for_local: bool = PrivateAttr(default=False) # Guard JS fetch path for local regular downloads
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> None:
self.logger.debug(f'[DownloadsWatchdog] Received BrowserLaunchEvent, EventBus ID: {id(self.event_bus)}')
# Ensure downloads directory exists
downloads_path = self.browser_session.browser_profile.downloads_path
if downloads_path:
expanded_path = Path(downloads_path).expanduser().resolve()
expanded_path.mkdir(parents=True, exist_ok=True)
self.logger.debug(f'[DownloadsWatchdog] Ensured downloads directory exists: {expanded_path}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Monitor new tabs for downloads."""
# logger.info(f'[DownloadsWatchdog] TabCreatedEvent received for tab {event.target_id[-4:]}: {event.url}')
# Assert downloads path is configured (should always be set by BrowserProfile default)
assert self.browser_session.browser_profile.downloads_path is not None, 'Downloads path must be configured'
if event.target_id:
# logger.info(f'[DownloadsWatchdog] Found target for tab {event.target_id}, calling attach_to_target')
await self.attach_to_target(event.target_id)
else:
self.logger.warning(f'[DownloadsWatchdog] No target found for tab {event.target_id}')
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
"""Stop monitoring closed tabs."""
pass # No cleanup needed, browser context handles target lifecycle
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> None:
"""Handle browser state request events."""
cdp_session = self.browser_session.agent_focus
if not cdp_session:
return
url = await self.browser_session.get_current_page_url()
if not url:
return
target_id = cdp_session.target_id
self.event_bus.dispatch(
NavigationCompleteEvent(
event_type='NavigationCompleteEvent',
url=url,
target_id=target_id,
event_parent_id=event.event_id,
)
)
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Clean up when browser stops."""
# Cancel all CDP event handler tasks
for task in list(self._cdp_event_tasks):
if not task.done():
task.cancel()
# Wait for all tasks to complete cancellation
if self._cdp_event_tasks:
await asyncio.gather(*self._cdp_event_tasks, return_exceptions=True)
self._cdp_event_tasks.clear()
# Clean up CDP session
# CDP sessions are now cached and managed by BrowserSession
self._download_cdp_session = None
self._download_cdp_session_setup = False
# Clear other state
self._sessions_with_listeners.clear()
self._active_downloads.clear()
self._pdf_viewer_cache.clear()
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
"""Check for PDFs after navigation completes."""
self.logger.debug(f'[DownloadsWatchdog] NavigationCompleteEvent received for {event.url}, tab #{event.target_id[-4:]}')
# Clear PDF cache for the navigated URL since content may have changed
if event.url in self._pdf_viewer_cache:
del self._pdf_viewer_cache[event.url]
# Check if auto-download is enabled
auto_download_enabled = self._is_auto_download_enabled()
if not auto_download_enabled:
return
# Note: Using network-based PDF detection that doesn't require JavaScript
target_id = event.target_id
self.logger.debug(f'[DownloadsWatchdog] Got target_id={target_id} for tab #{event.target_id[-4:]}')
is_pdf = await self.check_for_pdf_viewer(target_id)
if is_pdf:
self.logger.debug(f'[DownloadsWatchdog] 📄 PDF detected at {event.url}, triggering auto-download...')
download_path = await self.trigger_pdf_download(target_id)
if not download_path:
self.logger.warning(f'[DownloadsWatchdog] ⚠️ PDF download failed for {event.url}')
def _is_auto_download_enabled(self) -> bool:
"""Check if auto-download PDFs is enabled in browser profile."""
return self.browser_session.browser_profile.auto_download_pdfs
async def attach_to_target(self, target_id: TargetID) -> None:
"""Set up download monitoring for a specific target."""
# Define CDP event handlers outside of try to avoid indentation/scope issues
async def download_will_begin_handler(event: DownloadWillBeginEvent, session_id: SessionID | None):
self.logger.debug(f'[DownloadsWatchdog] Download will begin: {event}')
# Cache info for later completion event handling (esp. remote browsers)
guid = event.get('guid', '')
try:
suggested_filename = event.get('suggestedFilename')
assert suggested_filename, 'CDP DownloadWillBegin missing suggestedFilename'
self._cdp_downloads_info[guid] = {
'url': event.get('url', ''),
'suggested_filename': suggested_filename,
'handled': False,
}
except (AssertionError, KeyError):
pass
# Create and track the task
task = asyncio.create_task(self._handle_cdp_download(event, target_id, session_id))
self._cdp_event_tasks.add(task)
# Remove from set when done
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
async def download_progress_handler(event: DownloadProgressEvent, session_id: SessionID | None):
# Check if download is complete
if event.get('state') == 'completed':
file_path = event.get('filePath')
guid = event.get('guid', '')
if self.browser_session.is_local:
if file_path:
self.logger.debug(f'[DownloadsWatchdog] Download completed: {file_path}')
# Track the download
self._track_download(file_path)
# Mark as handled to prevent fallback duplicate dispatch
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
else:
# No local file path provided, local polling in _handle_cdp_download will handle it
self.logger.debug(
'[DownloadsWatchdog] No filePath in progress event (local); polling will handle detection'
)
else:
# Remote browser: do not touch local filesystem. Fallback to downloadPath+suggestedFilename
info = self._cdp_downloads_info.get(guid, {})
try:
suggested_filename = info.get('suggested_filename') or (Path(file_path).name if file_path else 'download')
downloads_path = str(self.browser_session.browser_profile.downloads_path or '')
effective_path = file_path or str(Path(downloads_path) / suggested_filename)
file_name = Path(effective_path).name
file_ext = Path(file_name).suffix.lower().lstrip('.')
self.event_bus.dispatch(
FileDownloadedEvent(
url=info.get('url', ''),
path=str(effective_path),
file_name=file_name,
file_size=0,
file_type=file_ext if file_ext else None,
)
)
self.logger.debug(f'[DownloadsWatchdog] ✅ (remote) Download completed: {effective_path}')
finally:
if guid in self._cdp_downloads_info:
del self._cdp_downloads_info[guid]
try:
downloads_path_raw = self.browser_session.browser_profile.downloads_path
if not downloads_path_raw:
# logger.info(f'[DownloadsWatchdog] No downloads path configured, skipping target: {target_id}')
return # No downloads path configured
# Check if we already have a download listener on this session
# to prevent duplicate listeners from being added
# Note: Since download listeners are set up once per browser session, not per target,
# we just track if we've set up the browser-level listener
if self._download_cdp_session_setup:
self.logger.debug('[DownloadsWatchdog] Download listener already set up for browser session')
return
# logger.debug(f'[DownloadsWatchdog] Setting up CDP download listener for target: {target_id}')
# Use CDP session for download events but store reference in watchdog
if not self._download_cdp_session_setup:
# Set up CDP session for downloads (only once per browser session)
cdp_client = self.browser_session.cdp_client
# Set download behavior to allow downloads and enable events
downloads_path = self.browser_session.browser_profile.downloads_path
if not downloads_path:
self.logger.warning('[DownloadsWatchdog] No downloads path configured, skipping CDP download setup')
return
# Ensure path is properly expanded (~ -> absolute path)
expanded_downloads_path = Path(downloads_path).expanduser().resolve()
await cdp_client.send.Browser.setDownloadBehavior(
params={
'behavior': 'allow',
'downloadPath': str(expanded_downloads_path), # Use expanded absolute path
'eventsEnabled': True,
}
)
# Register the handlers with CDP
cdp_client.register.Browser.downloadWillBegin(download_will_begin_handler) # type: ignore[arg-type]
cdp_client.register.Browser.downloadProgress(download_progress_handler) # type: ignore[arg-type]
self._download_cdp_session_setup = True
self.logger.debug('[DownloadsWatchdog] Set up CDP download listeners')
# No need to track individual targets since download listener is browser-level
# logger.debug(f'[DownloadsWatchdog] Successfully set up CDP download listener for target: {target_id}')
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] Failed to set up CDP download listener for target {target_id}: {e}')
def _track_download(self, file_path: str) -> None:
"""Track a completed download and dispatch the appropriate event.
Args:
file_path: The path to the downloaded file
"""
try:
# Get file info
path = Path(file_path)
if path.exists():
file_size = path.stat().st_size
self.logger.debug(f'[DownloadsWatchdog] Tracked download: {path.name} ({file_size} bytes)')
# Dispatch download event
from browser_use.browser.events import FileDownloadedEvent
self.event_bus.dispatch(
FileDownloadedEvent(
url=str(path), # Use the file path as URL for local files
path=str(path),
file_name=path.name,
file_size=file_size,
)
)
else:
self.logger.warning(f'[DownloadsWatchdog] Downloaded file not found: {file_path}')
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] Error tracking download: {e}')
async def _handle_cdp_download(
self, event: DownloadWillBeginEvent, target_id: TargetID, session_id: SessionID | None
) -> None:
"""Handle a CDP Page.downloadWillBegin event."""
downloads_dir = (
Path(
self.browser_session.browser_profile.downloads_path
or f'{tempfile.gettempdir()}/browser_use_downloads.{str(self.browser_session.id)[-4:]}'
)
.expanduser()
.resolve()
) # Ensure path is properly expanded
# Initialize variables that may be used outside try blocks
unique_filename = None
file_size = 0
expected_path = None
download_result = None
download_url = event.get('url', '')
suggested_filename = event.get('suggestedFilename', 'download')
guid = event.get('guid', '')
try:
self.logger.debug(f'[DownloadsWatchdog] ⬇️ File download starting: {suggested_filename} from {download_url[:100]}...')
self.logger.debug(f'[DownloadsWatchdog] Full CDP event: {event}')
# Since Browser.setDownloadBehavior is already configured, the browser will download the file
# We just need to wait for it to appear in the downloads directory
expected_path = downloads_dir / suggested_filename
# Debug: List current directory contents
self.logger.debug(f'[DownloadsWatchdog] Downloads directory: {downloads_dir}')
if downloads_dir.exists():
files_before = list(downloads_dir.iterdir())
self.logger.debug(f'[DownloadsWatchdog] Files before download: {[f.name for f in files_before]}')
# Try manual JavaScript fetch as a fallback for local browsers (disabled for regular local downloads)
if self.browser_session.is_local and self._use_js_fetch_for_local:
self.logger.debug(f'[DownloadsWatchdog] Attempting JS fetch fallback for {download_url}')
unique_filename = None
file_size = None
download_result = None
try:
# Escape the URL for JavaScript
import json
escaped_url = json.dumps(download_url)
# Get the proper session for the frame that initiated the download
cdp_session = await self.browser_session.cdp_client_for_frame(event.get('frameId'))
assert cdp_session
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': f"""
(async () => {{
try {{
const response = await fetch({escaped_url});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return {{
data: Array.from(uint8Array),
size: uint8Array.length,
contentType: response.headers.get('content-type') || 'application/octet-stream'
}};
}} catch (error) {{
throw new Error(`Fetch failed: ${{error.message}}`);
}}
}})()
""",
'awaitPromise': True,
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
download_result = result.get('result', {}).get('value')
if download_result and download_result.get('data'):
# Save the file
file_data = bytes(download_result['data'])
file_size = len(file_data)
# Ensure unique filename
unique_filename = await self._get_unique_filename(str(downloads_dir), suggested_filename)
final_path = downloads_dir / unique_filename
# Write the file
import anyio
async with await anyio.open_file(final_path, 'wb') as f:
await f.write(file_data)
self.logger.debug(f'[DownloadsWatchdog] ✅ Downloaded and saved file: {final_path} ({file_size} bytes)')
expected_path = final_path
# Emit download event immediately
file_ext = expected_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
self.event_bus.dispatch(
FileDownloadedEvent(
url=download_url,
path=str(expected_path),
file_name=unique_filename or expected_path.name,
file_size=file_size or 0,
file_type=file_type,
mime_type=(download_result.get('contentType') if download_result else None),
from_cache=False,
auto_download=False,
)
)
# Mark as handled to prevent duplicate dispatch from progress/polling paths
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
self.logger.debug(
f'[DownloadsWatchdog] ✅ File download completed via CDP: {suggested_filename} ({file_size} bytes) saved to {expected_path}'
)
return
else:
self.logger.error('[DownloadsWatchdog] ❌ No data received from fetch')
except Exception as fetch_error:
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to download file via fetch: {fetch_error}')
# For remote browsers, don't poll local filesystem; downloadProgress handler will emit the event
if not self.browser_session.is_local:
return
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] ❌ Error handling CDP download: {type(e).__name__} {e}')
# If we reach here, the fetch method failed, so wait for native download
# Poll the downloads directory for new files
self.logger.debug(f'[DownloadsWatchdog] Checking if browser auto-download saved the file for us: {suggested_filename}')
# Get initial list of files in downloads directory
initial_files = set()
if Path(downloads_dir).exists():
for f in Path(downloads_dir).iterdir():
if f.is_file() and not f.name.startswith('.'):
initial_files.add(f.name)
# Poll for new files
max_wait = 20 # seconds
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < max_wait:
await asyncio.sleep(5.0) # Check every 5 seconds
if Path(downloads_dir).exists():
for file_path in Path(downloads_dir).iterdir():
# Skip hidden files and files that were already there
if file_path.is_file() and not file_path.name.startswith('.') and file_path.name not in initial_files:
# Check if file has content (> 4 bytes)
try:
file_size = file_path.stat().st_size
if file_size > 4:
# Found a new download!
self.logger.debug(
f'[DownloadsWatchdog] ✅ Found downloaded file: {file_path} ({file_size} bytes)'
)
# Determine file type from extension
file_ext = file_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
# Dispatch download event
# Skip if already handled by progress/JS fetch
info = self._cdp_downloads_info.get(guid, {})
if info.get('handled'):
return
self.event_bus.dispatch(
FileDownloadedEvent(
url=download_url,
path=str(file_path),
file_name=file_path.name,
file_size=file_size,
file_type=file_type,
)
)
# Mark as handled after dispatch
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
return
except Exception as e:
self.logger.debug(f'[DownloadsWatchdog] Error checking file {file_path}: {e}')
self.logger.warning(f'[DownloadsWatchdog] Download did not complete within {max_wait} seconds')
async def _handle_download(self, download: Any) -> None:
"""Handle a download event."""
download_id = f'{id(download)}'
self._active_downloads[download_id] = download
self.logger.debug(f'[DownloadsWatchdog] ⬇️ Handling download: {download.suggested_filename} from {download.url[:100]}...')
# Debug: Check if download is already being handled elsewhere
failure = (
await download.failure()
) # TODO: it always fails for some reason, figure out why connect_over_cdp makes accept_downloads not work
self.logger.warning(f'[DownloadsWatchdog] ❌ Download state - canceled: {failure}, url: {download.url}')
# logger.info(f'[DownloadsWatchdog] Active downloads count: {len(self._active_downloads)}')
try:
current_step = 'getting_download_info'
# Get download info immediately
url = download.url
suggested_filename = download.suggested_filename
current_step = 'determining_download_directory'
# Determine download directory from browser profile
downloads_dir = self.browser_session.browser_profile.downloads_path
if not downloads_dir:
downloads_dir = str(Path.home() / 'Downloads')
else:
downloads_dir = str(downloads_dir) # Ensure it's a string
# Check if Playwright already auto-downloaded the file (due to CDP setup)
original_path = Path(downloads_dir) / suggested_filename
if original_path.exists() and original_path.stat().st_size > 0:
self.logger.debug(
f'[DownloadsWatchdog] File already downloaded by Playwright: {original_path} ({original_path.stat().st_size} bytes)'
)
# Use the existing file instead of creating a duplicate
download_path = original_path
file_size = original_path.stat().st_size
unique_filename = suggested_filename
else:
current_step = 'generating_unique_filename'
# Ensure unique filename
unique_filename = await self._get_unique_filename(downloads_dir, suggested_filename)
download_path = Path(downloads_dir) / unique_filename
self.logger.debug(f'[DownloadsWatchdog] Download started: {unique_filename} from {url[:100]}...')
current_step = 'calling_save_as'
# Save the download using Playwright's save_as method
self.logger.debug(f'[DownloadsWatchdog] Saving download to: {download_path}')
self.logger.debug(f'[DownloadsWatchdog] Download path exists: {download_path.parent.exists()}')
self.logger.debug(f'[DownloadsWatchdog] Download path writable: {os.access(download_path.parent, os.W_OK)}')
try:
self.logger.debug('[DownloadsWatchdog] About to call download.save_as()...')
await download.save_as(str(download_path))
self.logger.debug(f'[DownloadsWatchdog] Successfully saved download to: {download_path}')
current_step = 'save_as_completed'
except Exception as save_error:
self.logger.error(f'[DownloadsWatchdog] save_as() failed with error: {save_error}')
raise save_error
# Get file info
file_size = download_path.stat().st_size if download_path.exists() else 0
# Determine file type from extension
file_ext = download_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
# Try to get MIME type from response headers if available
mime_type = None
# Note: Playwright doesn't expose response headers directly from Download object
# Check if this was a PDF auto-download
auto_download = False
if file_type == 'pdf':
auto_download = self._is_auto_download_enabled()
# Emit download event
self.event_bus.dispatch(
FileDownloadedEvent(
url=url,
path=str(download_path),
file_name=suggested_filename,
file_size=file_size,
file_type=file_type,
mime_type=mime_type,
from_cache=False,
auto_download=auto_download,
)
)
self.logger.debug(
f'[DownloadsWatchdog] ✅ Download completed: {suggested_filename} ({file_size} bytes) saved to {download_path}'
)
# File is now tracked on filesystem, no need to track in memory
except Exception as e:
self.logger.error(
f'[DownloadsWatchdog] Error handling download at step "{locals().get("current_step", "unknown")}", error: {e}'
)
self.logger.error(
f'[DownloadsWatchdog] Download state - URL: {download.url}, filename: {download.suggested_filename}'
)
finally:
# Clean up tracking
if download_id in self._active_downloads:
del self._active_downloads[download_id]
async def check_for_pdf_viewer(self, target_id: TargetID) -> bool:
"""Check if the current target is a PDF using network-based detection.
This method avoids JavaScript execution that can crash WebSocket connections.
Returns True if a PDF is detected and should be downloaded.
"""
self.logger.debug(f'[DownloadsWatchdog] Checking if target {target_id} is PDF viewer...')
# Get target info to get URL
cdp_client = self.browser_session.cdp_client
targets = await cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if not target_info:
self.logger.warning(f'[DownloadsWatchdog] No target info found for {target_id}')
return False
page_url = target_info.get('url', '')
# Check cache first
if page_url in self._pdf_viewer_cache:
cached_result = self._pdf_viewer_cache[page_url]
self.logger.debug(f'[DownloadsWatchdog] Using cached PDF check result for {page_url}: {cached_result}')
return cached_result
try:
# Method 1: Check URL patterns (fastest, most reliable)
url_is_pdf = self._check_url_for_pdf(page_url)
if url_is_pdf:
self.logger.debug(f'[DownloadsWatchdog] PDF detected via URL pattern: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Method 2: Check network response headers via CDP (safer than JavaScript)
header_is_pdf = await self._check_network_headers_for_pdf(target_id)
if header_is_pdf:
self.logger.debug(f'[DownloadsWatchdog] PDF detected via network headers: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Method 3: Check Chrome's PDF viewer specific URLs
chrome_pdf_viewer = self._is_chrome_pdf_viewer_url(page_url)
if chrome_pdf_viewer:
self.logger.debug(f'[DownloadsWatchdog] Chrome PDF viewer detected: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Not a PDF
self._pdf_viewer_cache[page_url] = False
return False
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] ❌ Error checking for PDF viewer: {e}')
self._pdf_viewer_cache[page_url] = False
return False
def _check_url_for_pdf(self, url: str) -> bool:
"""Check if URL indicates a PDF file."""
if not url:
return False
url_lower = url.lower()
# Direct PDF file extensions
if url_lower.endswith('.pdf'):
return True
# PDF in path
if '.pdf' in url_lower:
return True
# PDF MIME type in URL parameters
if any(
param in url_lower
for param in [
'content-type=application/pdf',
'content-type=application%2fpdf',
'mimetype=application/pdf',
'type=application/pdf',
]
):
return True
return False
def _is_chrome_pdf_viewer_url(self, url: str) -> bool:
"""Check if this is Chrome's internal PDF viewer URL."""
if not url:
return False
url_lower = url.lower()
# Chrome PDF viewer uses chrome-extension:// URLs
if 'chrome-extension://' in url_lower and 'pdf' in url_lower:
return True
# Chrome PDF viewer internal URLs
if url_lower.startswith('chrome://') and 'pdf' in url_lower:
return True
return False
async def _check_network_headers_for_pdf(self, target_id: TargetID) -> bool:
"""Infer PDF via navigation history/URL; headers are not available post-navigation in this context."""
try:
import asyncio
# Get CDP session
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Get navigation history to find the main resource
history = await asyncio.wait_for(
temp_session.cdp_client.send.Page.getNavigationHistory(session_id=temp_session.session_id), timeout=3.0
)
current_entry = history.get('entries', [])
if current_entry:
current_index = history.get('currentIndex', 0)
if 0 <= current_index < len(current_entry):
current_url = current_entry[current_index].get('url', '')
# Check if the URL itself suggests PDF
if self._check_url_for_pdf(current_url):
return True
# Note: CDP doesn't easily expose response headers for completed navigations
# For more complex cases, we'd need to set up Network.responseReceived listeners
# before navigation, but that's overkill for most PDF detection cases
return False
except Exception as e:
self.logger.debug(f'[DownloadsWatchdog] Network headers check failed (non-critical): {e}')
return False
async def trigger_pdf_download(self, target_id: TargetID) -> str | None:
"""Trigger download of a PDF from Chrome's PDF viewer.
Returns the download path if successful, None otherwise.
"""
self.logger.debug(f'[DownloadsWatchdog] trigger_pdf_download called for target_id={target_id}')
if not self.browser_session.browser_profile.downloads_path:
self.logger.warning('[DownloadsWatchdog] ❌ No downloads path configured, cannot save PDF download')
return None
downloads_path = self.browser_session.browser_profile.downloads_path
self.logger.debug(f'[DownloadsWatchdog] Downloads path: {downloads_path}')
try:
# Create a temporary CDP session for this target without switching focus
import asyncio
self.logger.debug(f'[DownloadsWatchdog] Creating CDP session for PDF download from target {target_id}')
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Try to get the PDF URL with timeout
result = await asyncio.wait_for(
temp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
// For Chrome's PDF viewer, the actual URL is in window.location.href
// The embed element's src is often "about:blank"
const embedElement = document.querySelector('embed[type="application/x-google-chrome-pdf"]') ||
document.querySelector('embed[type="application/pdf"]');
if (embedElement) {
// Chrome PDF viewer detected - use the page URL
return { url: window.location.href };
}
// Fallback to window.location.href anyway
return { url: window.location.href };
})()
""",
'returnByValue': True,
},
session_id=temp_session.session_id,
),
timeout=5.0, # 5 second timeout to prevent hanging
)
pdf_info = result.get('result', {}).get('value', {})
pdf_url = pdf_info.get('url', '')
if not pdf_url:
self.logger.warning(f'[DownloadsWatchdog] ❌ Could not determine PDF URL for download {pdf_info}')
return None
# Generate filename from URL
pdf_filename = os.path.basename(pdf_url.split('?')[0]) # Remove query params
if not pdf_filename or not pdf_filename.endswith('.pdf'):
parsed = urlparse(pdf_url)
pdf_filename = os.path.basename(parsed.path) or 'document.pdf'
if not pdf_filename.endswith('.pdf'):
pdf_filename += '.pdf'
self.logger.debug(f'[DownloadsWatchdog] Generated filename: {pdf_filename}')
# Check if already downloaded by looking in the downloads directory
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
if os.path.exists(downloads_dir):
existing_files = os.listdir(downloads_dir)
if pdf_filename in existing_files:
self.logger.debug(f'[DownloadsWatchdog] PDF already downloaded: {pdf_filename}')
return None
self.logger.debug(f'[DownloadsWatchdog] Starting PDF download from: {pdf_url[:100]}...')
# Download using JavaScript fetch to leverage browser cache
try:
# Properly escape the URL to prevent JavaScript injection
escaped_pdf_url = json.dumps(pdf_url)
result = await asyncio.wait_for(
temp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': f"""
(async () => {{
try {{
// Use fetch with cache: 'force-cache' to prioritize cached version
const response = await fetch({escaped_pdf_url}, {{
cache: 'force-cache'
}});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Check if served from cache
const fromCache = response.headers.has('age') ||
!response.headers.has('date');
return {{
data: Array.from(uint8Array),
fromCache: fromCache,
responseSize: uint8Array.length,
transferSize: response.headers.get('content-length') || 'unknown'
}};
}} catch (error) {{
throw new Error(`Fetch failed: ${{error.message}}`);
}}
}})()
""",
'awaitPromise': True,
'returnByValue': True,
},
session_id=temp_session.session_id,
),
timeout=10.0, # 10 second timeout for download operation
)
download_result = result.get('result', {}).get('value', {})
if download_result and download_result.get('data') and len(download_result['data']) > 0:
# Ensure unique filename
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
# Ensure downloads directory exists
os.makedirs(downloads_dir, exist_ok=True)
unique_filename = await self._get_unique_filename(downloads_dir, pdf_filename)
download_path = os.path.join(downloads_dir, unique_filename)
# Save the PDF asynchronously
async with await anyio.open_file(download_path, 'wb') as f:
await f.write(bytes(download_result['data']))
# Verify file was written successfully
if os.path.exists(download_path):
actual_size = os.path.getsize(download_path)
self.logger.debug(
f'[DownloadsWatchdog] PDF file written successfully: {download_path} ({actual_size} bytes)'
)
else:
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to write PDF file to: {download_path}')
return None
# Log cache information
cache_status = 'from cache' if download_result.get('fromCache') else 'from network'
response_size = download_result.get('responseSize', 0)
self.logger.debug(
f'[DownloadsWatchdog] ✅ Auto-downloaded PDF ({cache_status}, {response_size:,} bytes): {download_path}'
)
# Emit file downloaded event
self.logger.debug(f'[DownloadsWatchdog] Dispatching FileDownloadedEvent for {unique_filename}')
self.event_bus.dispatch(
FileDownloadedEvent(
url=pdf_url,
path=download_path,
file_name=unique_filename,
file_size=response_size,
file_type='pdf',
mime_type='application/pdf',
from_cache=download_result.get('fromCache', False),
auto_download=True,
)
)
# No need to detach - session is cached
return download_path
else:
self.logger.warning(f'[DownloadsWatchdog] No data received when downloading PDF from {pdf_url}')
return None
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] Failed to auto-download PDF from {pdf_url}: {type(e).__name__}: {e}')
return None
except TimeoutError:
self.logger.debug('[DownloadsWatchdog] PDF download operation timed out')
return None
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] Error in PDF download: {type(e).__name__}: {e}')
return None
@staticmethod
async def _get_unique_filename(directory: str, filename: str) -> str:
"""Generate a unique filename for downloads by appending (1), (2), etc., if a file already exists."""
base, ext = os.path.splitext(filename)
counter = 1
new_filename = filename
while os.path.exists(os.path.join(directory, new_filename)):
new_filename = f'{base} ({counter}){ext}'
counter += 1
return new_filename
# Fix Pydantic circular dependency - this will be called from session.py after BrowserSession is defined
@@ -0,0 +1,456 @@
"""Local browser watchdog for managing browser subprocess lifecycle."""
import asyncio
import os
import shutil
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
import psutil
from bubus import BaseEvent
from pydantic import PrivateAttr
from browser_use.browser.events import (
BrowserKillEvent,
BrowserLaunchEvent,
BrowserLaunchResult,
BrowserStopEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.observability import observe_debug
if TYPE_CHECKING:
pass
class LocalBrowserWatchdog(BaseWatchdog):
"""Manages local browser subprocess lifecycle."""
# Events this watchdog listens to
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
BrowserLaunchEvent,
BrowserKillEvent,
BrowserStopEvent,
]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
# Private state for subprocess management
_subprocess: psutil.Process | None = PrivateAttr(default=None)
_owns_browser_resources: bool = PrivateAttr(default=True)
_temp_dirs_to_cleanup: list[Path] = PrivateAttr(default_factory=list)
_original_user_data_dir: str | None = PrivateAttr(default=None)
@observe_debug(ignore_input=True, ignore_output=True, name='browser_launch_event')
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> BrowserLaunchResult:
"""Launch a local browser process."""
try:
self.logger.debug('[LocalBrowserWatchdog] Received BrowserLaunchEvent, launching local browser...')
# self.logger.debug('[LocalBrowserWatchdog] Calling _launch_browser...')
process, cdp_url = await self._launch_browser()
self._subprocess = process
# self.logger.debug(f'[LocalBrowserWatchdog] _launch_browser returned: process={process}, cdp_url={cdp_url}')
return BrowserLaunchResult(cdp_url=cdp_url)
except Exception as e:
self.logger.error(f'[LocalBrowserWatchdog] Exception in on_BrowserLaunchEvent: {e}', exc_info=True)
raise
async def on_BrowserKillEvent(self, event: BrowserKillEvent) -> None:
"""Kill the local browser subprocess."""
self.logger.debug('[LocalBrowserWatchdog] Killing local browser process')
if self._subprocess:
await self._cleanup_process(self._subprocess)
self._subprocess = None
# Clean up temp directories if any were created
for temp_dir in self._temp_dirs_to_cleanup:
self._cleanup_temp_dir(temp_dir)
self._temp_dirs_to_cleanup.clear()
# Restore original user_data_dir if it was modified
if self._original_user_data_dir is not None:
self.browser_session.browser_profile.user_data_dir = self._original_user_data_dir
self._original_user_data_dir = None
self.logger.debug('[LocalBrowserWatchdog] Browser cleanup completed')
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Listen for BrowserStopEvent and dispatch BrowserKillEvent without awaiting it."""
if self.browser_session.is_local and self._subprocess:
self.logger.debug('[LocalBrowserWatchdog] BrowserStopEvent received, dispatching BrowserKillEvent')
# Dispatch BrowserKillEvent without awaiting so it gets processed after all BrowserStopEvent handlers
self.event_bus.dispatch(BrowserKillEvent())
@observe_debug(ignore_input=True, ignore_output=True, name='launch_browser_process')
async def _launch_browser(self, max_retries: int = 3) -> tuple[psutil.Process, str]:
"""Launch browser process and return (process, cdp_url).
Handles launch errors by falling back to temporary directories if needed.
Returns:
Tuple of (psutil.Process, cdp_url)
"""
# Keep track of original user_data_dir to restore if needed
profile = self.browser_session.browser_profile
self._original_user_data_dir = str(profile.user_data_dir) if profile.user_data_dir else None
self._temp_dirs_to_cleanup = []
for attempt in range(max_retries):
try:
# Get launch args from profile
launch_args = profile.get_args()
# Add debugging port
debug_port = self._find_free_port()
launch_args.extend(
[
f'--remote-debugging-port={debug_port}',
]
)
assert '--user-data-dir' in str(launch_args), (
'User data dir must be set somewhere in launch args to a non-default path, otherwise Chrome will not let us attach via CDP'
)
# Get browser executable
# Priority: custom executable > fallback paths > playwright subprocess
if profile.executable_path:
browser_path = profile.executable_path
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Using custom local browser executable_path= {browser_path}')
else:
# self.logger.debug('[LocalBrowserWatchdog] 🔍 Looking for local browser binary path...')
# Try fallback paths first (system browsers preferred)
browser_path = self._find_installed_browser_path()
if not browser_path:
self.logger.error(
'[LocalBrowserWatchdog] ⚠️ No local browser binary found, installing browser using playwright subprocess...'
)
browser_path = await self._install_browser_with_playwright()
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Found local browser installed at executable_path= {browser_path}')
if not browser_path:
raise RuntimeError('No local Chrome/Chromium install found, and failed to install with playwright')
# Launch browser subprocess directly
self.logger.debug(f'[LocalBrowserWatchdog] 🚀 Launching browser subprocess with {len(launch_args)} args...')
subprocess = await asyncio.create_subprocess_exec(
browser_path,
*launch_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self.logger.debug(
f'[LocalBrowserWatchdog] 🎭 Browser running with browser_pid= {subprocess.pid} 🔗 listening on CDP port :{debug_port}'
)
# Convert to psutil.Process
process = psutil.Process(subprocess.pid)
# Wait for CDP to be ready and get the URL
cdp_url = await self._wait_for_cdp_url(debug_port)
# Success! Clean up any temp dirs we created but didn't use
for tmp_dir in self._temp_dirs_to_cleanup:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
return process, cdp_url
except Exception as e:
error_str = str(e).lower()
# Check if this is a user_data_dir related error
if any(err in error_str for err in ['singletonlock', 'user data directory', 'cannot create', 'already in use']):
self.logger.warning(f'Browser launch failed (attempt {attempt + 1}/{max_retries}): {e}')
if attempt < max_retries - 1:
# Create a temporary directory for next attempt
tmp_dir = Path(tempfile.mkdtemp(prefix='browseruse-tmp-'))
self._temp_dirs_to_cleanup.append(tmp_dir)
# Update profile to use temp directory
profile.user_data_dir = str(tmp_dir)
self.logger.debug(f'Retrying with temporary user_data_dir: {tmp_dir}')
# Small delay before retry
await asyncio.sleep(0.5)
continue
# Not a recoverable error or last attempt failed
# Restore original user_data_dir before raising
if self._original_user_data_dir is not None:
profile.user_data_dir = self._original_user_data_dir
# Clean up any temp dirs we created
for tmp_dir in self._temp_dirs_to_cleanup:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
raise
# Should not reach here, but just in case
if self._original_user_data_dir is not None:
profile.user_data_dir = self._original_user_data_dir
raise RuntimeError(f'Failed to launch browser after {max_retries} attempts')
@staticmethod
def _find_installed_browser_path() -> str | None:
"""Try to find browser executable from common fallback locations.
Prioritizes:
1. System Chrome Stable
1. Playwright chromium
2. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave)
3. Playwright headless-shell fallback
Returns:
Path to browser executable or None if not found
"""
import glob
import platform
from pathlib import Path
system = platform.system()
patterns = []
# Get playwright browsers path from environment variable if set
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
if system == 'Darwin': # macOS
if not playwright_path:
playwright_path = '~/Library/Caches/ms-playwright'
patterns = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
f'{playwright_path}/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
f'{playwright_path}/chromium_headless_shell-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
]
elif system == 'Linux':
if not playwright_path:
playwright_path = '~/.cache/ms-playwright'
patterns = [
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/usr/local/bin/google-chrome',
f'{playwright_path}/chromium-*/chrome-linux/chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/local/bin/chromium',
'/snap/bin/chromium',
'/usr/bin/google-chrome-beta',
'/usr/bin/google-chrome-dev',
'/usr/bin/brave-browser',
f'{playwright_path}/chromium_headless_shell-*/chrome-linux/chrome',
]
elif system == 'Windows':
if not playwright_path:
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
patterns = [
r'C:\Program Files\Google\Chrome\Application\chrome.exe',
r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe',
r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe',
r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe',
f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe',
r'C:\Program Files\Chromium\Application\chrome.exe',
r'C:\Program Files (x86)\Chromium\Application\chrome.exe',
r'%LOCALAPPDATA%\Chromium\Application\chrome.exe',
r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe',
r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe',
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
r'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe',
f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe',
]
for pattern in patterns:
# Expand user home directory
expanded_pattern = Path(pattern).expanduser()
# Handle Windows environment variables
if system == 'Windows':
pattern_str = str(expanded_pattern)
for env_var in ['%LOCALAPPDATA%', '%PROGRAMFILES%', '%PROGRAMFILES(X86)%']:
if env_var in pattern_str:
env_key = env_var.strip('%').replace('(X86)', ' (x86)')
env_value = os.environ.get(env_key, '')
if env_value:
pattern_str = pattern_str.replace(env_var, env_value)
expanded_pattern = Path(pattern_str)
# Convert to string for glob
pattern_str = str(expanded_pattern)
# Check if pattern contains wildcards
if '*' in pattern_str:
# Use glob to expand the pattern
matches = glob.glob(pattern_str)
if matches:
# Sort matches and take the last one (alphanumerically highest version)
matches.sort()
browser_path = matches[-1]
if Path(browser_path).exists() and Path(browser_path).is_file():
return browser_path
else:
# Direct path check
if expanded_pattern.exists() and expanded_pattern.is_file():
return str(expanded_pattern)
return None
async def _install_browser_with_playwright(self) -> str:
"""Get browser executable path from playwright in a subprocess to avoid thread issues."""
# Run in subprocess with timeout
process = await asyncio.create_subprocess_exec(
'uvx',
'playwright',
'install',
'chrome',
'--with-deps',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Playwright install output: {stdout}')
browser_path = self._find_installed_browser_path()
if browser_path:
return browser_path
self.logger.error(f'[LocalBrowserWatchdog] ❌ Playwright local browser installation error: \n{stdout}\n{stderr}')
raise RuntimeError('No local browser path found after: uvx playwright install chrome --with-deps')
except TimeoutError:
# Kill the subprocess if it times out
process.kill()
await process.wait()
raise RuntimeError('Timeout getting browser path from playwright')
except Exception as e:
# Make sure subprocess is terminated
if process.returncode is None:
process.kill()
await process.wait()
raise RuntimeError(f'Error getting browser path: {e}')
@staticmethod
def _find_free_port() -> int:
"""Find a free port for the debugging interface."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.listen(1)
port = s.getsockname()[1]
return port
@staticmethod
async def _wait_for_cdp_url(port: int, timeout: float = 30) -> str:
"""Wait for the browser to start and return the CDP URL."""
import aiohttp
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < timeout:
try:
async with aiohttp.ClientSession() as session:
async with session.get(f'http://localhost:{port}/json/version') as resp:
if resp.status == 200:
# Chrome is ready
return f'http://localhost:{port}/'
else:
# Chrome is starting up and returning 502/500 errors
await asyncio.sleep(0.1)
except Exception:
# Connection error - Chrome might not be ready yet
await asyncio.sleep(0.1)
raise TimeoutError(f'Browser did not start within {timeout} seconds')
@staticmethod
async def _cleanup_process(process: psutil.Process) -> None:
"""Clean up browser process.
Args:
process: psutil.Process to terminate
"""
if not process:
return
try:
# Try graceful shutdown first
process.terminate()
# Use async wait instead of blocking wait
for _ in range(50): # Wait up to 5 seconds (50 * 0.1)
if not process.is_running():
return
await asyncio.sleep(0.1)
# If still running after 5 seconds, force kill
if process.is_running():
process.kill()
# Give it a moment to die
await asyncio.sleep(0.1)
except psutil.NoSuchProcess:
# Process already gone
pass
except Exception:
# Ignore any other errors during cleanup
pass
def _cleanup_temp_dir(self, temp_dir: Path | str) -> None:
"""Clean up temporary directory.
Args:
temp_dir: Path to temporary directory to remove
"""
if not temp_dir:
return
try:
temp_path = Path(temp_dir)
# Only remove if it's actually a temp directory we created
if 'browseruse-tmp-' in str(temp_path):
shutil.rmtree(temp_path, ignore_errors=True)
except Exception as e:
self.logger.debug(f'Failed to cleanup temp dir {temp_dir}: {e}')
@property
def browser_pid(self) -> int | None:
"""Get the browser process ID."""
if self._subprocess:
return self._subprocess.pid
return None
@staticmethod
async def get_browser_pid_via_cdp(browser) -> int | None:
"""Get the browser process ID via CDP SystemInfo.getProcessInfo.
Args:
browser: Playwright Browser instance
Returns:
Process ID or None if failed
"""
try:
cdp_session = await browser.new_browser_cdp_session()
result = await cdp_session.send('SystemInfo.getProcessInfo')
process_info = result.get('processInfo', {})
pid = process_info.get('id')
await cdp_session.detach()
return pid
except Exception:
# If we can't get PID via CDP, it's not critical
return None
@@ -0,0 +1,43 @@
"""Permissions watchdog for granting browser permissions on connection."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from browser_use.browser.events import BrowserConnectedEvent
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class PermissionsWatchdog(BaseWatchdog):
"""Grants browser permissions when browser connects."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = []
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Grant permissions when browser connects."""
permissions = self.browser_session.browser_profile.permissions
if not permissions:
self.logger.debug('No permissions to grant')
return
self.logger.debug(f'🔓 Granting browser permissions: {permissions}')
try:
# Grant permissions using CDP Browser.grantPermissions
# origin=None means grant to all origins
# Browser domain commands don't use session_id
await self.browser_session.cdp_client.send.Browser.grantPermissions(
params={'permissions': permissions} # type: ignore
)
self.logger.debug(f'✅ Successfully granted permissions: {permissions}')
except Exception as e:
self.logger.error(f'❌ Failed to grant permissions: {str(e)}')
# Don't raise - permissions are not critical to browser operation
@@ -0,0 +1,112 @@
"""Watchdog for handling JavaScript dialogs (alert, confirm, prompt) automatically."""
import asyncio
from typing import ClassVar
from bubus import BaseEvent
from pydantic import PrivateAttr
from browser_use.browser.events import TabCreatedEvent
from browser_use.browser.watchdog_base import BaseWatchdog
class PopupsWatchdog(BaseWatchdog):
"""Handles JavaScript dialogs (alert, confirm, prompt) by automatically accepting them immediately."""
# Events this watchdog listens to and emits
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [TabCreatedEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
# Track which targets have dialog handlers registered
_dialog_listeners_registered: set[str] = PrivateAttr(default_factory=set)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.logger.debug(f'🚀 PopupsWatchdog initialized with browser_session={self.browser_session}, ID={id(self)}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Set up JavaScript dialog handling when a new tab is created."""
target_id = event.target_id
self.logger.debug(f'🎯 PopupsWatchdog received TabCreatedEvent for target {target_id}')
# Skip if we've already registered for this target
if target_id in self._dialog_listeners_registered:
self.logger.debug(f'Already registered dialog handlers for target {target_id}')
return
self.logger.debug(f'📌 Starting dialog handler setup for target {target_id}')
try:
# Get all CDP sessions for this target and any child frames
cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id, focus=False
) # don't auto-focus new tabs! sometimes we need to open tabs in background
# Also register for the root CDP client to catch dialogs from any frame
if self.browser_session._cdp_client_root:
self.logger.debug('📌 Also registering handler on root CDP client')
# Set up async handler for JavaScript dialogs - accept immediately without event dispatch
async def handle_dialog(event_data, session_id: str | None = None):
"""Handle JavaScript dialog events - accept immediately."""
try:
dialog_type = event_data.get('type', 'alert')
message = event_data.get('message', '')
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - attempting to accept...")
self.logger.debug('Trying all approaches to accept dialog...')
# Approach 1: Use the session that detected the dialog
if self.browser_session._cdp_client_root and session_id:
try:
self.logger.debug(f'🔄 Approach 1: Using session {session_id}')
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': True},
session_id=session_id,
),
timeout=0.25,
)
except (TimeoutError, Exception) as e:
pass
# Approach 2: Try with current agent focus session
if self.browser_session._cdp_client_root and self.browser_session.agent_focus:
try:
self.logger.debug(
f'🔄 Approach 2: Using agent focus session {self.browser_session.agent_focus.session_id}'
)
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': True},
session_id=self.browser_session.agent_focus.session_id,
),
timeout=0.25,
)
except (TimeoutError, Exception) as e:
pass
except Exception as e:
self.logger.error(f'❌ Critical error in dialog handler: {type(e).__name__}: {e}')
# Register handler on the specific session
cdp_session.cdp_client.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
self.logger.debug(
f'Successfully registered Page.javascriptDialogOpening handler for session {cdp_session.session_id}'
)
# Also register on root CDP client to catch dialogs from any frame
if hasattr(self.browser_session._cdp_client_root, 'register'):
try:
self.browser_session._cdp_client_root.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
self.logger.debug('Successfully registered dialog handler on root CDP client for all frames')
except Exception as root_error:
self.logger.warning(f'Failed to register on root CDP client: {root_error}')
# Mark this target as having dialog handling set up
self._dialog_listeners_registered.add(target_id)
self.logger.debug(f'Set up JavaScript dialog handling for tab {target_id}')
except Exception as e:
self.logger.warning(f'Failed to set up popup handling for tab {target_id}: {e}')
@@ -0,0 +1,126 @@
"""Recording Watchdog for Browser Use Sessions."""
import asyncio
from pathlib import Path
from typing import ClassVar
from bubus import BaseEvent
from cdp_use.cdp.page.events import ScreencastFrameEvent
from uuid_extensions import uuid7str
from browser_use.browser.events import BrowserConnectedEvent, BrowserStopEvent
from browser_use.browser.profile import ViewportSize
from browser_use.browser.video_recorder import VideoRecorderService
from browser_use.browser.watchdog_base import BaseWatchdog
class RecordingWatchdog(BaseWatchdog):
"""
Manages video recording of a browser session using CDP screencasting.
"""
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
_recorder: VideoRecorderService | None = None
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""
Starts video recording if it is configured in the browser profile.
"""
profile = self.browser_session.browser_profile
if not profile.record_video_dir:
return
# Dynamically determine video size
size = profile.record_video_size
if not size:
self.logger.debug('record_video_size not specified, detecting viewport size...')
size = await self._get_current_viewport_size()
if not size:
self.logger.warning('Cannot start video recording: viewport size could not be determined.')
return
video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
self.logger.debug(f'Initializing video recorder for format: {video_format}')
self._recorder = VideoRecorderService(output_path=output_path, size=size, framerate=profile.record_video_framerate)
self._recorder.start()
if not self._recorder._is_active:
self._recorder = None
return
self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
try:
cdp_session = await self.browser_session.get_or_create_cdp_session()
await cdp_session.cdp_client.send.Page.startScreencast(
params={
'format': 'png',
'quality': 90,
'maxWidth': size['width'],
'maxHeight': size['height'],
'everyNthFrame': 1,
},
session_id=cdp_session.session_id,
)
self.logger.info(f'📹 Started video recording to {output_path}')
except Exception as e:
self.logger.error(f'Failed to start screencast via CDP: {e}')
if self._recorder:
self._recorder.stop_and_save()
self._recorder = None
async def _get_current_viewport_size(self) -> ViewportSize | None:
"""Gets the current viewport size directly from the browser via CDP."""
try:
cdp_session = await self.browser_session.get_or_create_cdp_session()
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
# Use cssVisualViewport for the most accurate representation of the visible area
viewport = metrics.get('cssVisualViewport', {})
width = viewport.get('clientWidth')
height = viewport.get('clientHeight')
if width and height:
self.logger.debug(f'Detected viewport size: {width}x{height}')
return ViewportSize(width=int(width), height=int(height))
except Exception as e:
self.logger.warning(f'Failed to get viewport size from browser: {e}')
return None
def on_screencastFrame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
"""
Synchronous handler for incoming screencast frames.
"""
if not self._recorder:
return
self._recorder.add_frame(event['data'])
asyncio.create_task(self._ack_screencast_frame(event, session_id))
async def _ack_screencast_frame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
"""
Asynchronously acknowledges a screencast frame.
"""
try:
await self.browser_session.cdp_client.send.Page.screencastFrameAck(
params={'sessionId': event['sessionId']}, session_id=session_id
)
except Exception as e:
self.logger.debug(f'Failed to acknowledge screencast frame: {e}')
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""
Stops the video recording and finalizes the video file.
"""
if self._recorder:
recorder = self._recorder
self._recorder = None
self.logger.debug('Stopping video recording and saving file...')
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, recorder.stop_and_save)
@@ -0,0 +1,62 @@
"""Screenshot watchdog for handling screenshot requests using CDP."""
from typing import TYPE_CHECKING, Any, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.page import CaptureScreenshotParameters
from browser_use.browser.events import ScreenshotEvent
from browser_use.browser.views import BrowserError
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.observability import observe_debug
if TYPE_CHECKING:
pass
class ScreenshotWatchdog(BaseWatchdog):
"""Handles screenshot requests using CDP."""
# Events this watchdog listens to
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [ScreenshotEvent]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
@observe_debug(ignore_input=True, ignore_output=True, name='screenshot_event_handler')
async def on_ScreenshotEvent(self, event: ScreenshotEvent) -> str:
"""Handle screenshot request using CDP.
Args:
event: ScreenshotEvent with optional full_page and clip parameters
Returns:
Dict with 'screenshot' key containing base64-encoded screenshot or None
"""
self.logger.debug('[ScreenshotWatchdog] Handler START - on_ScreenshotEvent called')
try:
# Get CDP client and session for current target
cdp_session = await self.browser_session.get_or_create_cdp_session()
# Prepare screenshot parameters
params = CaptureScreenshotParameters(format='png', captureBeyondViewport=False)
# Take screenshot using CDP
self.logger.debug(f'[ScreenshotWatchdog] Taking screenshot with params: {params}')
result = await cdp_session.cdp_client.send.Page.captureScreenshot(params=params, session_id=cdp_session.session_id)
# Return base64-encoded screenshot data
if result and 'data' in result:
self.logger.debug('[ScreenshotWatchdog] Screenshot captured successfully')
return result['data']
raise BrowserError('[ScreenshotWatchdog] Screenshot result missing data')
except Exception as e:
self.logger.error(f'[ScreenshotWatchdog] Screenshot failed: {e}')
raise
finally:
# Try to remove highlights even on failure
try:
await self.browser_session.remove_highlights()
except Exception:
pass
@@ -0,0 +1,219 @@
"""Security watchdog for enforcing URL access policies."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from browser_use.browser.events import (
BrowserErrorEvent,
NavigateToUrlEvent,
NavigationCompleteEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
# Track if we've shown the glob warning
_GLOB_WARNING_SHOWN = False
class SecurityWatchdog(BaseWatchdog):
"""Monitors and enforces security policies for URL access."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
NavigateToUrlEvent,
NavigationCompleteEvent,
TabCreatedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
BrowserErrorEvent,
]
async def on_NavigateToUrlEvent(self, event: NavigateToUrlEvent) -> None:
"""Check if navigation URL is allowed before navigation starts."""
# Security check BEFORE navigation
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ Blocking navigation to disallowed URL: {event.url}')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NavigationBlocked',
message=f'Navigation blocked to disallowed URL: {event.url}',
details={'url': event.url, 'reason': 'not_in_allowed_domains'},
)
)
# Stop event propagation by raising exception
raise ValueError(f'Navigation to {event.url} blocked by security policy')
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
"""Check if navigated URL is allowed and close tab if not."""
# Check if the navigated URL is allowed (in case of redirects)
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ Navigation to non-allowed URL detected: {event.url}')
# Dispatch browser error
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NavigationBlocked',
message=f'Navigation to non-allowed URL: {event.url}',
details={'url': event.url, 'target_id': event.target_id},
)
)
# Close the target that navigated to the disallowed URL
try:
await self.browser_session._cdp_close_page(event.target_id)
self.logger.info(f'⛔️ Closed target with non-allowed URL: {event.url}')
except Exception as e:
self.logger.error(f'⛔️ Failed to close target with non-allowed URL: {type(e).__name__} {e}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Check if new tab URL is allowed."""
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ New tab created with disallowed URL: {event.url}')
# Dispatch error and try to close the tab
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='TabCreationBlocked',
message=f'Tab created with non-allowed URL: {event.url}',
details={'url': event.url, 'target_id': event.target_id},
)
)
# Try to close the offending tab
try:
await self.browser_session._cdp_close_page(event.target_id)
self.logger.info(f'⛔️ Closed new tab with non-allowed URL: {event.url}')
except Exception as e:
self.logger.error(f'⛔️ Failed to close new tab with non-allowed URL: {type(e).__name__} {e}')
def _is_root_domain(self, domain: str) -> bool:
"""Check if a domain is a root domain (no subdomain present).
Simple heuristic: only add www for domains with exactly 1 dot (domain.tld).
For complex cases like country TLDs or subdomains, users should configure explicitly.
Args:
domain: The domain to check
Returns:
True if it's a simple root domain, False otherwise
"""
# Skip if it contains wildcards or protocol
if '*' in domain or '://' in domain:
return False
return domain.count('.') == 1
def _log_glob_warning(self) -> None:
"""Log a warning about glob patterns in allowed_domains."""
global _GLOB_WARNING_SHOWN
if not _GLOB_WARNING_SHOWN:
_GLOB_WARNING_SHOWN = True
self.logger.warning(
'⚠️ Using glob patterns in allowed_domains. '
'Note: Patterns like "*.example.com" will match both subdomains AND the main domain.'
)
def _is_url_allowed(self, url: str) -> bool:
"""Check if a URL is allowed based on the allowed_domains configuration.
Args:
url: The URL to check
Returns:
True if the URL is allowed, False otherwise
"""
# If no allowed_domains specified, allow all URLs
if (
not self.browser_session.browser_profile.allowed_domains
and not self.browser_session.browser_profile.prohibited_domains
):
return True
# Always allow internal browser targets
if url in ['about:blank', 'chrome://new-tab-page/', 'chrome://new-tab-page', 'chrome://newtab/']:
return True
# Parse the URL to extract components
from urllib.parse import urlparse
try:
parsed = urlparse(url)
except Exception:
# Invalid URL
return False
# Get the actual host (domain)
host = parsed.hostname
if not host:
return False
# Check each allowed domain pattern
if self.browser_session.browser_profile.allowed_domains:
for pattern in self.browser_session.browser_profile.allowed_domains:
if self._is_url_match(url, host, parsed.scheme, pattern):
return True
return False
# Check each prohibited domain pattern
if self.browser_session.browser_profile.prohibited_domains:
for pattern in self.browser_session.browser_profile.prohibited_domains:
if self._is_url_match(url, host, parsed.scheme, pattern):
return False
return True
return True
def _is_url_match(self, url: str, host: str, scheme: str, pattern: str) -> bool:
"""Check if a URL matches a pattern."""
# Full URL for matching (scheme + host)
full_url_pattern = f'{scheme}://{host}'
# Handle glob patterns
if '*' in pattern:
self._log_glob_warning()
import fnmatch
# Check if pattern matches the host
if pattern.startswith('*.'):
# Pattern like *.example.com should match subdomains and main domain
domain_part = pattern[2:] # Remove *.
if host == domain_part or host.endswith('.' + domain_part):
# Only match http/https URLs for domain-only patterns
if scheme in ['http', 'https']:
return True
elif pattern.endswith('/*'):
# Pattern like brave://* should match any brave:// URL
prefix = pattern[:-1] # Remove the * at the end
if url.startswith(prefix):
return True
else:
# Use fnmatch for other glob patterns
if fnmatch.fnmatch(
full_url_pattern if '://' in pattern else host,
pattern,
):
return True
else:
# Exact match
if '://' in pattern:
# Full URL pattern
if url.startswith(pattern):
return True
else:
# Domain-only pattern (case-insensitive comparison)
if host.lower() == pattern.lower():
return True
# If pattern is a root domain, also check www subdomain
if self._is_root_domain(pattern) and host.lower() == f'www.{pattern.lower()}':
return True
return False
@@ -0,0 +1,335 @@
"""Storage state watchdog for managing browser cookies and storage persistence."""
import asyncio
import json
import os
from pathlib import Path
from typing import Any, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.network import Cookie
from pydantic import Field, PrivateAttr
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserStopEvent,
LoadStorageStateEvent,
SaveStorageStateEvent,
StorageStateLoadedEvent,
StorageStateSavedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
class StorageStateWatchdog(BaseWatchdog):
"""Monitors and persists browser storage state including cookies and localStorage."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
BrowserStopEvent,
SaveStorageStateEvent,
LoadStorageStateEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
StorageStateSavedEvent,
StorageStateLoadedEvent,
]
# Configuration
auto_save_interval: float = Field(default=30.0) # Auto-save every 30 seconds
save_on_change: bool = Field(default=True) # Save immediately when cookies change
# Private state
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
_last_cookie_state: list[dict] = PrivateAttr(default_factory=list)
_save_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Start monitoring when browser starts."""
self.logger.debug('[StorageStateWatchdog] 🍪 Initializing auth/cookies sync <-> with storage_state.json file')
# Start monitoring
await self._start_monitoring()
# Automatically load storage state after browser start
await self.event_bus.dispatch(LoadStorageStateEvent())
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Stop monitoring when browser stops."""
self.logger.debug('[StorageStateWatchdog] Stopping storage_state monitoring')
await self._stop_monitoring()
async def on_SaveStorageStateEvent(self, event: SaveStorageStateEvent) -> None:
"""Handle storage state save request."""
# Use provided path or fall back to profile default
path = event.path
if path is None:
# Use profile default path if available
if self.browser_session.browser_profile.storage_state:
path = str(self.browser_session.browser_profile.storage_state)
else:
path = None # Skip saving if no path available
await self._save_storage_state(path)
async def on_LoadStorageStateEvent(self, event: LoadStorageStateEvent) -> None:
"""Handle storage state load request."""
# Use provided path or fall back to profile default
path = event.path
if path is None:
# Use profile default path if available
if self.browser_session.browser_profile.storage_state:
path = str(self.browser_session.browser_profile.storage_state)
else:
path = None # Skip loading if no path available
await self._load_storage_state(path)
async def _start_monitoring(self) -> None:
"""Start the monitoring task."""
if self._monitoring_task and not self._monitoring_task.done():
return
assert self.browser_session.cdp_client is not None
self._monitoring_task = asyncio.create_task(self._monitor_storage_changes())
# self.logger'[StorageStateWatchdog] Started storage monitoring task')
async def _stop_monitoring(self) -> None:
"""Stop the monitoring task."""
if self._monitoring_task and not self._monitoring_task.done():
self._monitoring_task.cancel()
try:
await self._monitoring_task
except asyncio.CancelledError:
pass
# self.logger.debug('[StorageStateWatchdog] Stopped storage monitoring task')
async def _check_for_cookie_changes_cdp(self, event: dict) -> None:
"""Check if a CDP network event indicates cookie changes.
This would be called by Network.responseReceivedExtraInfo events
if we set up CDP event listeners.
"""
try:
# Check for Set-Cookie headers in the response
headers = event.get('headers', {})
if 'set-cookie' in headers or 'Set-Cookie' in headers:
self.logger.debug('[StorageStateWatchdog] Cookie change detected via CDP')
# If save on change is enabled, trigger save immediately
if self.save_on_change:
await self._save_storage_state()
except Exception as e:
self.logger.warning(f'[StorageStateWatchdog] Error checking for cookie changes: {e}')
async def _monitor_storage_changes(self) -> None:
"""Periodically check for storage changes and auto-save."""
while True:
try:
await asyncio.sleep(self.auto_save_interval)
# Check if cookies have changed
if await self._have_cookies_changed():
self.logger.debug('[StorageStateWatchdog] Detected changes to sync with storage_state.json')
await self._save_storage_state()
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Error in monitoring loop: {e}')
async def _have_cookies_changed(self) -> bool:
"""Check if cookies have changed since last save."""
if not self.browser_session.cdp_client:
return False
try:
# Get current cookies using CDP
current_cookies = await self.browser_session._cdp_get_cookies()
# Convert to comparable format, using .get() for optional fields
current_cookie_set = {
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in current_cookies
}
last_cookie_set = {
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in self._last_cookie_state
}
return current_cookie_set != last_cookie_set
except Exception as e:
self.logger.debug(f'[StorageStateWatchdog] Error comparing cookies: {e}')
return False
async def _save_storage_state(self, path: str | None = None) -> None:
"""Save browser storage state to file."""
async with self._save_lock:
# Check if CDP client is available
assert await self.browser_session.get_or_create_cdp_session(target_id=None, new_socket=False)
save_path = path or self.browser_session.browser_profile.storage_state
if not save_path:
return
# Skip saving if the storage state is already a dict (indicates it was loaded from memory)
# We only save to file if it started as a file path
if isinstance(save_path, dict):
self.logger.debug('[StorageStateWatchdog] Storage state is already a dict, skipping file save')
return
try:
# Get current storage state using CDP
storage_state = await self.browser_session._cdp_get_storage_state()
# Update our last known state
self._last_cookie_state = storage_state.get('cookies', []).copy()
# Convert path to Path object
json_path = Path(save_path).expanduser().resolve()
json_path.parent.mkdir(parents=True, exist_ok=True)
# Merge with existing state if file exists
merged_state = storage_state
if json_path.exists():
try:
existing_state = json.loads(json_path.read_text())
merged_state = self._merge_storage_states(existing_state, dict(storage_state))
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to merge with existing state: {e}')
# Write atomically
temp_path = json_path.with_suffix('.json.tmp')
temp_path.write_text(json.dumps(merged_state, indent=4))
# Backup existing file
if json_path.exists():
backup_path = json_path.with_suffix('.json.bak')
json_path.replace(backup_path)
# Move temp to final
temp_path.replace(json_path)
# Emit success event
self.event_bus.dispatch(
StorageStateSavedEvent(
path=str(json_path),
cookies_count=len(merged_state.get('cookies', [])),
origins_count=len(merged_state.get('origins', [])),
)
)
self.logger.debug(
f'[StorageStateWatchdog] Saved storage state to {json_path} '
f'({len(merged_state.get("cookies", []))} cookies, '
f'{len(merged_state.get("origins", []))} origins)'
)
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to save storage state: {e}')
async def _load_storage_state(self, path: str | None = None) -> None:
"""Load browser storage state from file."""
if not self.browser_session.cdp_client:
self.logger.warning('[StorageStateWatchdog] No CDP client available for loading')
return
load_path = path or self.browser_session.browser_profile.storage_state
if not load_path or not os.path.exists(str(load_path)):
return
try:
# Read the storage state file asynchronously
import anyio
content = await anyio.Path(str(load_path)).read_text()
storage = json.loads(content)
# Apply cookies if present
if 'cookies' in storage and storage['cookies']:
await self.browser_session._cdp_set_cookies(storage['cookies'])
self._last_cookie_state = storage['cookies'].copy()
self.logger.debug(f'[StorageStateWatchdog] Added {len(storage["cookies"])} cookies from storage state')
# Apply origins (localStorage/sessionStorage) if present
if 'origins' in storage and storage['origins']:
for origin in storage['origins']:
if 'localStorage' in origin:
for item in origin['localStorage']:
script = f"""
window.localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
"""
await self.browser_session._cdp_add_init_script(script)
if 'sessionStorage' in origin:
for item in origin['sessionStorage']:
script = f"""
window.sessionStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
"""
await self.browser_session._cdp_add_init_script(script)
self.logger.debug(
f'[StorageStateWatchdog] Applied localStorage/sessionStorage from {len(storage["origins"])} origins'
)
self.event_bus.dispatch(
StorageStateLoadedEvent(
path=str(load_path),
cookies_count=len(storage.get('cookies', [])),
origins_count=len(storage.get('origins', [])),
)
)
self.logger.debug(f'[StorageStateWatchdog] Loaded storage state from: {load_path}')
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to load storage state: {e}')
@staticmethod
def _merge_storage_states(existing: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
"""Merge two storage states, with new values taking precedence."""
merged = existing.copy()
# Merge cookies
existing_cookies = {(c['name'], c['domain'], c['path']): c for c in existing.get('cookies', [])}
for cookie in new.get('cookies', []):
key = (cookie['name'], cookie['domain'], cookie['path'])
existing_cookies[key] = cookie
merged['cookies'] = list(existing_cookies.values())
# Merge origins
existing_origins = {origin['origin']: origin for origin in existing.get('origins', [])}
for origin in new.get('origins', []):
existing_origins[origin['origin']] = origin
merged['origins'] = list(existing_origins.values())
return merged
async def get_current_cookies(self) -> list[dict[str, Any]]:
"""Get current cookies using CDP."""
if not self.browser_session.cdp_client:
return []
try:
cookies = await self.browser_session._cdp_get_cookies()
# Cookie is a TypedDict, cast to dict for compatibility
return [dict(cookie) for cookie in cookies]
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to get cookies: {e}')
return []
async def add_cookies(self, cookies: list[dict[str, Any]]) -> None:
"""Add cookies using CDP."""
if not self.browser_session.cdp_client:
self.logger.warning('[StorageStateWatchdog] No CDP client available for adding cookies')
return
try:
# Convert dicts to Cookie objects
cookie_objects = [Cookie(**cookie_dict) if isinstance(cookie_dict, dict) else cookie_dict for cookie_dict in cookies]
# Set cookies using CDP
await self.browser_session._cdp_set_cookies(cookie_objects)
self.logger.debug(f'[StorageStateWatchdog] Added {len(cookies)} cookies')
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to add cookies: {e}')